feat(mcp_server): hostowany serwer MCP w serwisie (/mcp, /mcp/auth) - #804
Open
mpasternak wants to merge 29 commits into
Open
feat(mcp_server): hostowany serwer MCP w serwisie (/mcp, /mcp/auth)#804mpasternak wants to merge 29 commits into
mpasternak wants to merge 29 commits into
Conversation
Pierwszy szkic specu `/mcp` montowanego w asgi.py, z narzędziami z pakietu
bpp-mcp i dostępem do danych przez httpx.ASGITransport.
Recenzja adwersarialna (Fable) orzekła: wymaga przeprojektowania, nie
poprawek. Commituję jako zapis stanu — kolejny commit przepisuje.
Blokery:
* Lifespan. FastMCP.streamable_http_app() startuje session_manager.run()
w lifespanie Starlette; ProtocolTypeRouter (channels) rzuca ValueError
na scope lifespan, uvicorn w trybie auto loguje "unsupported" i jedzie
dalej. run() nigdy nie wchodzi → RuntimeError("Task group is not
initialized") → każdy POST /mcp kończy się 500. Także w stateless.
* Model hosta. Wymyślony host (bpp.invalid) nie przechodzi ALLOWED_HOSTS,
a po dopisaniu go — SiteResolutionMiddleware i Uczelnia.get_for_request
dają Uczelnia=None, przez co BramkaApiV1.has_permission zwraca True
bezwarunkowo (obejście wszystkich przełączników API) i ukryte_statusy
= None (wyciek rekordów ukrytych statusów). W multi-hosted to wyciek
między uczelniami.
Bezpieczeństwo:
* allowlista nagłówków przepuszcza Authorization: Basic — logowanie
hasłem przez /mcp, bez scope/revoke/zgody, a ApiReadOnlyForBearer
sprawdza tylko prefiks "bearer ", więc read-only nie obowiązuje,
* BppClient._cache kluczowany samym URL-em — współdzielony między
użytkownikami i uczelniami do recyklingu workera,
* ASGITransport ustawia client=127.0.0.1, a spec odcina XFF → wszyscy
anonimowi dzielą jeden kubełek throttlingu,
* ModSecurity/CRS przed appserverem; wyłączenie obejmuje tylko
^/(bpp|api/v1)/zapytanie/, więc JSON-RPC z DjangoQL dostanie 403.
Fakty do poprawienia: 11 narzędzi + prompt (nie 7); rejestrowane są
wrappery z server.py, nie funkcje z tools.py; allowlista DCR dopuszcza
wyłącznie claude.ai/claude.com/localhost, więc ChatGPT i Cursor nie
zarejestrują klienta; mcp i httpx nie występują w uv.lock BPP.
Zakres rozbity na trzy projekty: (1) hosting w BPP, (2) przebudowa
bpp-mcp, (3) refaktor oauth_mcp/tokens.py.
Co się obroniło: brak zakleszczenia przy zagnieżdżonym żądaniu ASGI —
ASGIHandler owija je w ThreadSensitiveContext, a handler /mcp jest
czysto async i nie siedzi w kontekście sync.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU
Poprzedni commit zostawił spec z werdyktem "wymaga przeprojektowania".
Ta wersja odpowiada na wszystkie ustalenia recenzji, a dwa z nich weryfikuje
uruchomionym kodem zamiast lekturą.
Bloker lifespanu: POTWIERDZONY także na MCP SDK 2.0 — probe pokazał
RuntimeError("Task group is not initialized") przy POST /mcp bez wejścia
w lifespan, również w trybie stateless. Ale werdykt "przeprojektować" był
za mocny: rozwiązaniem jest własny dyspozytor ASGI obsługujący scope
`lifespan` zamiast ProtocolTypeRouter (ten rzuca na nim ValueError, a uvicorn
loguje to jako "unsupported" i jedzie dalej — stąd cisza zamiast błędu).
Po wejściu w lifespan ta sama ścieżka oddaje 200 z poprawnym `initialize`.
Wariant montażu A zostaje.
Bloker hosta: POTWIERDZONY i groźniejszy, niż zgłoszono. `bpp.invalid` nie
jest tylko problemem ALLOWED_HOSTS — Uczelnia rozstrzyga się z nagłówka Host,
a BramkaApiV1 przy Uczelnia=None PRZEPUSZCZA bezwarunkowo (krok 1 jej
docstringa). Czyli API wyłączone przez administratora byłoby dostępne przez
/mcp, a ukryte_statusy=None przestałoby filtrować rekordy ukrytych statusów.
Wewnętrzne żądanie musi dziedziczyć Host, scheme i IP klienta.
Bezpieczeństwo — dołożone: przekazujemy WYŁĄCZNIE schemat Bearer, nigdy
surowy Authorization (Basic jest trzeci w DEFAULT_AUTHENTICATION_CLASSES,
a ApiReadOnlyForBearer sprawdza tylko prefiks "bearer ", więc dla Basica
warstwa read-only nie istnieje) ani Cookie (SessionAuthentication).
Throttling: ASGITransport ustawia client=127.0.0.1, więc bez propagacji IP
wszyscy anonimowi dzielą jeden kubełek.
Nowe, spoza recenzji, z probe'ów: stateless_http i json_response przeniosły
się w SDK 2.0 z konstruktora do streamable_http_app(); json_response=True
znosi SSE, co adresuje proxy_read_timeout nginksa; aplikacja MCP ma WŁASNĄ
allowlistę hostów (TransportSecuritySettings) niezależną od ALLOWED_HOSTS —
bez jej zasilenia wszystkie wdrożenia poza jednym dostaną 421.
Zakres rozbity na trzy projekty. Ten spec to projekt 1 (hosting). Projekt 2
(szwy w bpp-mcp) jest zrobiony — 0.4.0 na PyPI. Projekt 3 (konsolidacja
reguł tokenu w oauth_mcp/tokens.py) wypada do osobnego specu: to zmiana
w testowanej warstwie uwierzytelniania i wrzucanie jej do środka feature'a
było błędem.
Poprawione fakty: 11 narzędzi + prompt (nie 7), rejestrowane są wrappery
z server.py; whoami ma routing w api_v1/urls.py pod z_bramka_api_v1;
max_requests siedzi w gunicorn_conf.py, nie w entrypoincie; trzy strefy
limit_req, nie jedna; ModSecurity wymaga zmiany w bpp-deploy; allowlista
DCR dopuszcza wyłącznie ekosystem Claude, więc obietnica zasięgu o ChatGPT
i Cursorze była nieprawdziwa.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU
Przeczytanie źródła channels (routing.py:45-51) zmienia rozwiązanie blokera
lifespanu na wyraźnie prostsze.
ProtocolTypeRouter NIE odrzuca lifespanu — odrzuca to, czego nie ma w jego
słowniku. To piętnaście linii: lookup, a dla nieznanego typu raise ValueError.
`lifespan` jest zwyczajnym typem scope'u, dokładnie tej samej kategorii co
`http` i `websocket`; klucza po prostu u nas nie ma.
Poprzednia wersja sekcji proponowała ZASTĄPIENIE routera własnym dyspozytorem.
Działa (probe to pokazał), ale bierze na nas odtworzenie gałęzi websocketowej
razem z AllowedHostsOriginValidator i AuthMiddlewareStack — czyli ruszanie
działającej warstwy bezpieczeństwa WebSocketów po to, żeby naprawić rzecz,
która jej w ogóle nie dotyczy.
Zamiast tego dopisujemy klucz:
"lifespan": LifespanMcp(mcp_app)
a gałąź websocketowa zostaje nietknięta bit w bit. Do napisania zostają dwa
małe obiekty ASGI: LifespanMcp (wchodzi/wychodzi z kontekstu aplikacji MCP,
błąd startu raportuje jako lifespan.startup.failed zamiast go połykać) oraz
RouterHttp (rozdziela po ścieżce, nie po metodzie — SDK obsługuje na trasie
streamable także GET i DELETE).
Patchowanie channels rozważone i odrzucone: jego zachowanie nie jest błędne,
a monkeypatch byłby niewidoczny w asgi.py i pękłby przy aktualizacji.
Poprawiony też §2.4 — dotąd twierdził, że „ProtocolTypeRouter rzuca ValueError
na scope lifespan", co sugerowało ograniczenie biblioteki. To brakujący wpis
w naszej konfiguracji, nie cudzy bug.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU
Wersja 2 dostała werdykt "niewdrażalne — trzy blokery". Wszystkie trzy są
zamknięte, plus dwanaście poprawek.
B1 — "base_url składany per żądanie" było niewykonalne: BppClient zapamiętuje
api_root w __init__ (client.py:135), Config jest frozen, a klient z lifespanu
to jeden obiekt na proces. Rozwiązanie (D9): lifespan oddaje obiekt, którego
`client` jest property czytającym ContextVar, czyli KLIENT PER ŻĄDANIE.
Mieści się w udokumentowanym kontrakcie register_tools ("obiekt o tych samych
atrybutach"), więc nie wymaga zmian w bpp-mcp. Rozwiązuje naraz trzy rzeczy:
api_root z właściwego hosta, izolację cache i semafor per żądanie. Odrzucone
alternatywy: nadpisywanie prywatnego _full_url (kontrakt, którego nikt nie
obiecał utrzymać) oraz wydanie 0.4.1 (unieważniałoby "projekt 2 zrobiony").
B2 — lifespan nie dochodzi pod Daphne, a to Daphne obsługuje `manage.py
runserver` (czyli `run-site run`, jedyną zalecaną w CLAUDE.md drogę oglądania
strony) oraz channels_live_server w testach Playwright. W źródłach daphne nie
ma ani jednego wystąpienia słowa "lifespan". Sam dopisany klucz naprawiał więc
wyłącznie produkcję. Rozwiązanie (D2): start menedżera sesji jest idempotentny
i wyzwalany z obu stron — z lifespanu pod uvicornem, z pierwszego żądania pod
Daphne, pod blokadą z double-checkiem (run() można wejść raz na instancję).
B3 — 401 z WWW-Authenticate nie miało wykonawcy. SDK owija trasę
RequireAuthMiddleware wyłącznie gdy podano token_verifier, a ten odrzuca 401-ką
KAŻDE żądanie bez tożsamości, czyli kasuje dostęp anonimowy. Rozwiązanie (D10):
własna BramkaBearera i DWA ADRESY — /mcp publiczny, /mcp/auth zawsze 401 bez
tokenu. Taniec OAuth startuje wtedy przy `initialize`, w jedynym momencie, co
do którego mamy pewność, że obsługuje go każdy klient. To usuwa zależność od
nieznanego zachowania klientów przy 401 na późniejszym żądaniu — czyli zamyka
§15.1, które nie było otwartą kwestią, tylko ukrytą decyzją architektoniczną.
Poprawki: KlientScope za transportem (IP klienta jest nieosiągalne przed
aplikacją MCP, bo ASGITransport buduje własny scope); propagacja scheme
i follow_redirects=False (SECURE_SSL_REDIRECT po cichu podwajał każde żądanie
wewnętrzne, a FirstRunWizardMiddleware przekierowuje /api/v1/ na HTML kreatora
— to zamyka §15.6); semantyka allowlisty hostów SDK różna od ALLOWED_HOSTS
plus sprawdzanie Origin plus to, że host= bez transport_security po cichu
wyłącza ochronę; jawny sufit czasu i współbieżności (D11 — timeouty httpx są
przy ASGITransport martwe, a gunicornowy timeout to heartbeat); redirect
GET /mcp na /mcp/ (inaczej 406 dla człowieka z przeglądarki); jawne
report_exc_info dla warstwy MCP (zamyka §15.7); brak XFF w żądaniu
wewnętrznym; boot-loop przy startup.failed nazwany jako oczekiwany; fabryka
build_application() wymagana przez testy; stateless_http + GET to 405, nie SSE;
CountdownBlockingMiddleware nie zwalnia /api/.
Delta zależności policzona: dziesięć nowych pakietów, w tym httpx i httpx2
naraz (mcp 2.0 przeszło na drugi klient HTTP). Kolizji starlette nie ma —
zamyka §15.2.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU
Recenzja potwierdziła D9 (klient per żądanie) jako zamknięte i prześledziła mechanizm do końca: SDK celowo przenosi kontekst przez granicę zadań — transport zapisuje copy_context() nadawcy przy wysłaniu (_context_streams.py), a dispatcher odtwarza go przez sender_ctx.run(tg.start_soon, ...). Czyli ContextVar dociera do handlera z konstrukcji, nie z przypadku. To była największa niewiadoma wersji 3. Trzy nowe blokery — wszystkie w rozwiązaniach B2/B3 z wersji 3, żaden w architekturze: BL-1: /mcp/auth zwracałoby 404. SDK montuje trasę jako Route (regex ^/mcp$), nie Mount, więc drugi adres nie istnieje w routingu. RouterHttp przepisuje scope["path"] na /mcp po przejściu przez bramkę; dopasowanie dokładne zamiast prefiksowego (przy okazji naprawia sprzeczność: /mcp/ miało iść do Django, a przy prefiksie trafiłoby do Starlette i dostało 307). BL-2: sufit czasu wokół aplikacji MCP nie zatrzymywał narzędzia. W trybie bezstanowym serwer JSON-RPC jest zadaniem grupy MENEDŻERA, a handler jest spawnowany do grupy dispatchera; fail_after wokół aplikacji anulował tylko zadanie czekające na odpowiedź, a narzędzie i jego N żądań do Django biegły dalej. Do tego terminate() nie jest w finally, więc każdy timeout wyciekał zadanie. Budżet i semafor przeniesione do BppClientInProcess i egzekwowane w _request — przekroczenie limitu ZATRZYMUJE pracę, a nie tylko przestaje na nią czekać. BL-3: leniwy start pękał pod własnym fail_after ze specu. Wejście w session_manager.run() z zadania żądania czyni je host taskiem grupy; aktywny inny cancel scope w tym zadaniu powoduje RuntimeError przy wyjściu, a grupa jest już oznaczona jako wystartowana, więc padają też wszystkie kolejne żądania. Anulowanie hosta zatruwa grupę bez możliwości restartu. Start przeniesiony do osobnego, długowiecznego zadania z Event gotowości. Poprawki: bramka musi sprawdzać to samo co DRF (is_valid ORAZ is_active ORAZ scope read — inaczej token bez scope przechodzi bramkę i pada dopiero w DRF, czyli wraca jako błąd JSON-RPC z HTTP 200 i klient nie robi re-auth) plus close_old_connections poza cyklem żądania; KlientScope fail-closed; Origin daje 403, nie 421, i reguła dla allowed_origins; wrapper wokół register_tools dla Rollbara (SDK zamienia wyjątki narzędzi na CallToolResult, więc do warstwy ASGI nic nie dociera); Cookie dociera do narzędzia przez SessionMessage.metadata .request, więc D6 stoi na dyscyplinie cudzego kodu i test musi być po stronie BPP; healthcheck rozróżniający stan procesu od stanu endpointu; sprostowanie zakresu Uczelnia=None (dotyczy wielouczelnianości, bo _site_dla_requestu spada na SITE_ID); wycofane niepotwierdzone twierdzenie o 405 dla GET. Zamknięte otwarte kwestie: anyio (mcp 2.0 wymaga >=4.9, BPP ma 4.11.0). Dopisane: walidacja pola resource w PRM przy dwóch adresach, zgłoszenie upstream braku terminate() w finally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU
Każde zadanie ma własny cykl testowy i kończy się niezależnie testowalnym rezultatem. Kolejność wymuszona zależnościami: StartMcp przed routingiem (bo router go woła), kontekst przed klientem (bo klient z niego czyta), bramka przed routerem (bo router montuje ją w dwóch trybach). Testy regresyjne przypięte do konkretnych blokerów z recenzji specu: * BL-1 — /mcp/auth ma przepisywaną ścieżkę na /mcp, bo SDK montuje trasę jako Route (^/mcp$), nie Mount, więc bez tego byłoby 404, * BL-2 — po wyczerpaniu budżetu klient NIE wykonuje kolejnych żądań; test liczy wywołania transportu, bo limit ma zatrzymywać pracę, a nie tylko przestawać na nią czekać, * BL-3 — zapewnij() wywołane z zadania z aktywnym fail_after nie psuje ani tego żądania, ani kolejnych. Plus regresje bezpieczeństwa: Cookie i Basic nie trafiają do żądania wewnętrznego, obcy host w URL-u paginacyjnego `next` odrzucony, dwie uczelnie na dwóch hostach widzą swoje dane, a wyłączone API odmawia także przez /mcp. Świadoma luka: ModSecurity i limit_req wymagają zmian w repozytorium bpp-deploy, więc nie da się ich wykonać w tym planie — wchodzą jako osobne zadanie wdrożeniowe po scaleniu. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU
Recenzja Task 1 zliczyła sekcje [[package]] dodane przez `uv lock` i wyszło 21, nie dziesięć. Moje oszacowanie w §9 liczyło wyłącznie zależności bezpośrednie i było zaniżone ponad dwukrotnie. Dwa ustalenia podnoszą wagę ryzyka, które §15 traktował jako rutynowe: * `pydantic-core` i `rpds-py` to kompilowane rozszerzenia Rust — osobne koła per platforma. Realny wzrost rozmiaru obrazu i osobna powierzchnia CVE dla bramki Trivy, czego pierwsza wersja szacunku w ogóle nie przewidywała. * Drzewo niesie DWA stosy HTTP: bpp-mcp zależy od httpx, a mcp 2.x od httpx2, każdy z własnym httpcore. Dochodzi httpx2-jsfetch z markerem sys_platform == 'emscripten', czyli kod WASM nieużywany w kontenerze Linux. To wzmacnia argument za przejściem bpp-mcp na httpx2 — dziś wozimy oba. Kod Task 1 jest bez zarzutu i zgodny z briefem co do znaku; to poprawka faktu w dokumencie, nie w implementacji. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU
…ości Naprawia dwa znaleziska z recenzji Task 2: 1. _AplikacjaZLifespanem.lifespan_context otwierał zwykly @asynccontextmanager bez grupy zadan anyio, więc nie zostawial na stosie zadania żadnego cancel scope'u - naiwna (blednie zaimplemen- towana) wersja StartMcp nie psula sie przez RuntimeError opisany w BL-3, tylko wisiala do timeoutu fail_after (5s, TimeoutError). Atrapa teraz otwiera prawdziwa anyio.create_task_group(), tak jak robi to session_manager.run() w SDK MCP - zweryfikowano pomiarem, ze naiwna implementacja teraz pada przez RuntimeError w <1s, nie przez TimeoutError po 5s. 2. Dodano test_wiele_rownoleglych_zapewnij_wchodzi_raz - pokrywa twierdzenie z komentarza w start.py o braku punktu przerwania miedzy sprawdzeniem i create_task (wszystkie dotychczasowe testy wolaly zapewnij() sekwencyjnie, nie sprawdzaly realnej wspolbieznosci przez asyncio.gather). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU
… jeden Runda 2 recenzji: recenzent odtworzył wariant B naiwnej implementacji (zapewnij() woła await self._trzymaj() wprost, bez create_task, zachowując poprawne zagnieżdżenie cancel scope'ow) i zmierzyl, ze pada przez TimeoutError po 5.11 s, nie przez RuntimeError. Test juz dyskryminowal oba warianty (A - RuntimeError, B - timeout) - defektem byl docstring start.py, ktory sugerowal jeden, zawsze ten sam wyjatek. - Docstring start.py: opisuje teraz DWA objawy zaleznie od tego, czy zagniezdzenie cancel scope'ow zostaje naruszone (RuntimeError) czy pozostaje poprawne, a zadanie po prostu nigdy nie wraca (zawieszenie, timeout). - test_start.py: anyio.fail_after(5) -> fail_after(1) w tescie regresyjnym BL-3 - przy poprawnej implementacji bez wplywu (test i tak kończy się natychmiast), przy regresji wariantu B skraca kare w CI z 5 s do 1 s. Nie dopisano drugiego testu regresyjnego dla wariantu B - nie da sie w nim zaasertowac wyjatku, ktory nie powstaje (jego jedynym objawem jest zawieszenie, ktore juz lapie istniejacy test przez timeout). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU
Dopisuje do klient.py klasę BppClientInProcess (BppClient wołający django_asgi_app przez ASGITransport, budowany PER ŻĄDANIE bo _api_root zależy od Hosta konkretnego żądania) i fabrykę zbuduj_klienta(). Budżet czasu (deadline z DaneZadania) egzekwowany w _request PRZED wykonaniem żądania (spec §5.2, BL-2) — limit wokół aplikacji MCP nie zatrzymałby pracy, tylko przestałby na nią czekać. Dodatkowe guardy: ścieżka musi zaczynać się od /api/v1/, host odpowiedzi musi zgadzać się z hostem żądania. Import django_bpp.asgi zostaje leniwy (wewnątrz __init__), żeby nie zamknąć cyklu asgi -> aplikacja -> klient -> asgi. Testy: 3 nowe (adres API z hosta żądania, dwa żądania dwa różne adresy, regresja BL-2 — przekroczony budżet nie wykonuje żadnego żądania transportu). Jedna świadoma odchyłka od briefu: pytest.raises(Exception) zamieniony na pytest.raises(BppError) — ruff B017 (blind exception) odrzucał gołe Exception. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU
Wykonawca Task 4 znalazł defekt w planie, którego nie wychwyciła ani żadna z trzech recenzji specu, ani pre-flight scan. `BppClient._auth_kwargs()` czyta token przez `bpp_mcp.auth.current_bearer()` — z ContextVara należącego do PAKIETU, osobnego od naszego `mcp_server.kontekst.dane_zadania`. Plan nigdzie nie wołał `set_current_bearer()`, więc pole `DaneZadania.bearer` nie miało ŻADNEJ drogi do żądania wychodzącego. Skutek byłby cichy i dotkliwy: każde wywołanie narzędzia leciałoby anonimowo, niezależnie od tego, czy użytkownik się zalogował. Zalogowany traciłby dostęp do swoich danych bez komunikatu, `/mcp/auth` przepuszczałby token przez bramkę i gubił go piętro niżej, a cała warstwa OAuth — trzy sesje pracy — byłaby dekoracją. Żaden test w planie by tego nie złapał, bo wszystkie sprawdzały ścieżkę anonimową. Task 6 ustawia teraz OBA ContextVary i czyści oba w finally (`set_current_bearer` nie zwraca tokenu resetu, więc czyścimy jawnie — inaczej token wyciekłby do następnego żądania w tym samym kontekście). Dwa nowe testy: token dociera do ContextVara pakietu, i nie wycieka do kolejnego żądania. Przy okazji usunięty martwy `_z_kontekstem` — no-op zwracający argument, odnotowany w pre-flight scanie. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU
RouterHttp rozdziela ruch: /mcp i /mcp/auth (dokladne dopasowanie, nie
prefiksowe - /mcp/ ze slashem idzie do Django) trafiaja do aplikacji MCP,
reszta do Django. /mcp/auth ma przepisywana sciezke na /mcp PRZED
wywolaniem aplikacji MCP, bo SDK montuje trase jako Starlette Route
(regex ^/mcp$), nie Mount - bez przepisania drugi adres dostalby 404
(spec Sec.5.1, BL-1). GET z Accept: text/html na /mcp przekierowuje
307 na /mcp/ dla czlowieka wklejajacego adres z instrukcji.
Mostek bearera do ContextVara PAKIETU bpp_mcp (set_current_bearer/
current_bearer) - BppClient._auth_kwargs czyta token z WLASNEGO
ContextVara bpp_mcp.auth, osobnego od mcp_server.kontekst.dane_zadania.
Bez tego DaneZadania.bearer nie mialby drogi do zadania wychodzacego
i kazde wywolanie lecialoby anonimowo. Ustawiany i czyszczony (jawnie,
set_current_bearer(None) w finally) razem z dane_zadania.
zapewnij() jest pierwsza instrukcja w RouterHttp.__call__ i POZA
jakimkolwiek cancel scope'em (spec Sec.5.1) - wejscie w grupe zadan
anyio z zadania w ktorym trwa inny scope psuje to zadanie i wszystkie
kolejne.
LifespanMcp obsluguje scope 'lifespan', ktorego ProtocolTypeRouter nie
zna - bez tego uvicorn loguje 'lifespan appears unsupported' i menedzer
sesji MCP nigdy nie wstaje.
Naprawiono 2 bledy briefu odkryte przy zderzeniu z kodem (patrz
task-6-report.md):
1. _dane() budowala slownik naglowkow z kluczami BYTES (k.lower() bez
.decode()), a odpytywala go kluczami str ('host', 'authorization',
'x-forwarded-proto') - kazdy lookup chybial i trafial w wartosc
domyslna. Bearer byl ZAWSZE None niezaleznie od naglowka -
mostek z BppClient wygladal podlaczony, a nic nie przenosil.
2. 3 z 6 testow z briefu uzywaly fikcyjnych bearerow ('TOKEN-XYZ', 'T1')
przechodzacych przez PRAWDZIWA BramkaBearera, ktora (poprawnie, zgodnie
ze spec Sec.5.4/D10) odrzuca kazdy nieistniejacy token 401-ka - na OBU
adresach, niezaleznie od wymagany=True/False. Testy dodaja teraz
prawdziwy AccessToken przez model_bakery (wzorem test_auth.py z Tasku 5).
Co do znaku z briefu: implementacja produkcyjna (poza poprawka nr 1) i
struktura testow.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU
Klucze nagłówków w ASGI to bytes, więc k.lower() też daje bytes, a wszystkie odczyty w _dane() szły po stringu. Każdy chybiał i dane.bearer był zawsze None — czyli mostek dopisany po Tasku 4 sam gubił token, tylko piętro wyżej. Wykonawca Task 6 naprawił to w kodzie; ten commit prostuje dokument, żeby nie został w repo z blokiem kodu, który nie działa. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU
Fabryka build_application() -> (mcp_app, start): buduje MCPServer("bpp"),
rejestruje 11 narzędzi + 1 prompt z bpp_mcp.register_tools, tłumaczy
ALLOWED_HOSTS na TransportSecuritySettings (host:* wildcard, "*" ==
wyłączenie DNS-rebinding protection bo Django-owe "*" nie ma odpowiednika
w SDK) i montuje aplikację ASGI stateless_http+json_response (spec D3).
KontekstZadania.client jest property, nie polem — klient budowany per
żądanie przez zbuduj_klienta(), zgodnie z furtką w kontrakcie lifespanu
register_tools ("obiekt o tych samych atrybutach").
API mcp==2.1.1 i bpp-mcp==0.4.0 zweryfikowane w zainstalowanym pakiecie
przed napisaniem kodu — brief się zgadzał 1:1, żadnych korekt nie było
potrzeba (patrz task-7-report.md).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU
Task 8: aplikacja MCP z build_application() jest teraz obsługiwana przez RouterHttp na kluczu "http" ProtocolTypeRouter (obok istniejącej gałęzi WebSocketów, nietkniętej), a lifespan MCP dostaje własny klucz "lifespan" przez LifespanMcp — inaczej ProtocolTypeRouter nie ma dla niego wpisu i menedżer sesji MCP nigdy nie wstaje. Nowe importy mcp_server.* idą po get_asgi_application(), z tego samego powodu co istniejący import liveops.routing: AppRegistry Django musi być zainicjalizowane, zanim mcp_server sięgnie po modele przez klienta. Test end-to-end (src/mcp_server/tests/test_e2e.py) woła RouterHttp zbudowany z build_application() wprost — POST /mcp → initialize → sprawdza serverInfo.name == "bpp" oraz 403/421 dla złego hosta. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU
Recenzja Task 8: żaden istniejący test nie padał po usunięciu montażu z django_bpp/asgi.py — test_e2e.py i reszta buduje RouterHttp/LifespanMcp własnoręcznie przez build_application(), więc przechodzi identycznie po przywróceniu starego "http": django_asgi_app bez klucza "lifespan". Nowy test_asgi_montaz.py odpala świeży import django_bpp.asgi w SUBPROCESIE (build_application() wykonuje się raz przy imporcie modułu, a session_manager.run() wchodzi się raz na instancję — w procesie pytest wszystkie testy dzieliłyby jedną, już użytą instancję z sys.modules) i: - sprawdza strukturalnie application.application_mapping["http"/"lifespan"] (isinstance RouterHttp/LifespanMcp), - przepuszcza przez samo application (ProtocolTypeRouter) pełny cykl lifespan.startup -> lifespan.shutdown i asercjuje komunikaty complete. Nietrywialność potwierdzona ręcznie: tymczasowe przywrócenie starego "http": django_asgi_app (bez "lifespan") dało AssertionError: 'http' to <class 'django.core.handlers.asgi.ASGIHandler'>, nie RouterHttp — MCP nie jest zamontowane obok Django. Zmiana przywrócona, potwierdzone git diff czyste. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU
Dopisuje GET /.well-known/oauth-protected-resource jako bliźniaka istniejącego RFC 8414 (oauth_authorization_server_metadata) — te same zasady budowania URL-i przez request.build_absolute_uri, wielo-domenowość, dostęp anonimowy. Testy nadpisują settings.ALLOWED_HOSTS dla użytych HTTP_HOST — bez tego klient testowy dostaje 400 (DisallowedHost) zamiast treści widoku; brief tego nie uwzględniał. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU
Instrukcja dla człowieka jak podłączyć klienta AI do BPP przez MCP — pokazuje oba adresy (/mcp publiczny, /mcp/auth z logowaniem OAuth), budowane per host przez request.build_absolute_uri. Dwie korekty względem brief-u: - extends "base.html" i block "content" zweryfikowane w repo (src/django_bpp/templates/base.html) — okazały się poprawne, brief zgadywał trafnie. - testy nadpisują settings.ALLOWED_HOSTS (fixture pytest-django) dla HTTP_HOST z briefu — bez tego Django odda 400 zamiast treści widoku, wzorzec z test_cache_vary_host.py. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU
- test_bezpieczenstwo.py: Cookie i Basic nie trafiają do żądania
wewnętrznego, ścieżka spoza /api/v1/ i obcy host w URL-u odrzucone
(pytest.raises(BppError), nie goły Exception — ruff B017).
- test_wielohost.py: dowód, że BramkaApiV1 działa na ścieżce /mcp
(wyłączone API odmawia) oraz że filtr ukryte_statusy("api") różni
się per Host — nie przez /api/v1/uczelnia/ (queryset bez scoping
po Site, zweryfikowane empirycznie: oba hosty widzą identyczną listę
uczelni), a przez UkryjStatusyKorektyMixin na wydawnictwo_ciagle/.
- test_dozwolone_hosty.py: pokrycie _dozwolone_hosty() (aplikacja.py) —
"*" -> [], host zwykły -> wariant dokładny + host:*, wiodąca kropka
Django -> bez kropki, "*" wygrywa niezależnie od pozycji na liście.
Weryfikacja mutacyjna (Task 11 pkt A): tymczasowe wycięcie strażników
ścieżki/hosta w BppClientInProcess._request wywaliło odpowiadające
testy (DID NOT RAISE BppError) — strażniki przywrócone, diff czysty.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU
…hanizmie Recenzja Task 11: zmutowanie gałęzi TrybAuth.W_PROCESIE w BppClient._auth_kwargs nie psuje test_basic_nie_trafia_do_zadania_wewnetrznego — bo BppClientInProcess.__init__ buduje Config bez basic_auth, więc auth_tuple jest zawsze None niezależnie od tej gałęzi. Podobnie test_cookie_nie_trafia_do_zadania_wewnetrznego pilnuje nieobecności forwardingu (żądanie konstruowane od zera), nie filtrowania nagłówków — w kodzie nie ma żadnej ścieżki cookies=. Docstringi poprawione, żeby opisywały RZECZYWISTY mechanizm (brak forwardingu) i realny powód, dla którego Basic akurat tu nie trafia (Config bez basic_auth), z ostrzeżeniem na przyszłość: dopisanie basic_auth= do Config w klient.py wymagałoby nowego testu ćwiczącego faktycznie gałąź TrybAuth.W_PROCESIE. Asercje i logika testów bez zmian. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU
…ment Task 12 (ostatnie zadanie planu wdrożenia MCP hostowanego w serwisie): - aplikacja.py: _z_raportowaniem(serwer) owija serwer.tool() tak, że wyjątek narzędzia trafia do rollbar.report_exc_info() PRZED tym, jak MCPServer._handle_call_tool zamieni go w CallToolResult(is_error=True) — bez tego awarie narzędzi są niewidoczne w monitoringu (spec §7.5). Zweryfikowane w zainstalowanym pakiecie mcp (nie zgadywane): tool() to dekorator-fabryka rejestrujący narzędzie natychmiast przy dekorowaniu, a inspect.signature(eval_str=True)/typing.get_type_hints — obie idą po __wrapped__ — więc functools.wraps wystarcza, żeby schemat wejściowy i wykrycie parametru Context przetrwały wrapper. - zdrowie.py: stan_mcp() odróżnia „proces działa" od „endpoint MCP działa" na podstawie StartMcp z django_bpp.asgi (spec §10.3). - Newsfragment (src/bpp/newsfragments/, kanoniczny katalog) + wzmianka o mcp_server/oauth_mcp w mapie kodu (Supporting Applications, Key URLs). - Testy: test_rollbar.py (mutacyjnie zweryfikowany — wyłączenie try/except w wrapperze psuje test), test_zdrowie.py. Pełna suita src/mcp_server + src/oauth_mcp + src/api_v1 + src/django_bpp/tests: 455 passed, 1 skipped. ruff check/format i pre-commit czyste na zmienionych plikach. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU
Sześć znalezisk z recenzji całej gałęzi, w jednym przejściu. B1 — bramka uczelni fail-closed (nowy `mcp_server/uczelnia.py`). ALLOWED_HOSTS na produkcji zawiera hosty infrastrukturalne (127.0.0.1, appserver, appserver:8000), które przechodziły obie bramki, a nie mają swojego Site. Uczelnia=None otwiera BramkaApiV1 bezwarunkowo, zeruje scope_rekord_do_uczelni i wyłącza UkryjStatusyKorektyMixin, więc `curl -H 'Host: appserver'` obchodził wyłącznik API i filtr ukrytych statusów. Sprawdzenie jest PER ŻĄDANIE (allowlista transportowa liczy się przy imporcie asgi.py i nie wolno jej sięgać do bazy; poza tym odpowiada za DNS rebinding, nie za wielotenantowość) i odrzuca 421. Instalacja jednouczelniana działa dalej — fallback na SITE_ID jest tam legalny i jawnie dopuszczony. B2 — BppError nie trafia już do Rollbara. To normalny kanał komunikatów do użytkownika (401 anonima, 404 encji, walidacja argumentu), a na publicznym endpoincie znaczyło zgłoszenie na każde wywołanie. Wyjątek od wyjątku: status 5xx pochodzi z odpowiedzi naszego Django, więc mówi o awarii po naszej stronie i jest raportowany. B3 — gałąź 503 przestała być martwym kodem: zapewnij() rzucało, zanim wykonanie doszło do sprawdzenia `zywy`, więc wyjątek wychodził poza aplikację ASGI jako gołe 500. CancelledError nie jest już zapamiętywany jako trwały błąd ani raportowany (przychodzi z każdego zamknięcia workera). Awaria startu idzie do Rollbara ze StartMcp — raz na awarię, nie raz na żądanie. Nowy, tani endpoint `GET /mcp/status` (bez auth, bez bazy, bez Django) rozróżnia „proces żyje” od „endpoint MCP działa”; świadomie NIE w /health/, bo tamto restartuje kontener. B4 — schemat czytany z nagłówka wskazanego przez SECURE_PROXY_SSL_HEADER, w auth.py i routing.py z jednego miejsca. Wcześniej PRM w WWW-Authenticate wskazywał http:// na produkcji (gunicorn nie ustawia forwarded_allow_ips, więc uvicorn nie ufa X-Forwarded-Proto), co psuło discovery OAuth. Test asertuje pełny URL, nie sam podciąg. N1 — log audytowy każdego żądania /mcp: ścieżka, kod, czas, host, OBECNOŚĆ bearera (nigdy wartość). Żądania te nie trafiają ani do access-logu nginksa, ani uvicorna. N2 — test initialize → tools/list → tools/call przez pełny stos, z asercją na rozpakowaną treść wyniku narzędzia. Każda naprawa zweryfikowana mutacyjnie — szczegóły w raporcie fali. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU
Spec §7.2 opisuje bramkę uczelni fail-closed (421 dla hosta, który nie rozstrzyga uczelni) i mówi, dlaczego sprawdzenie musi być per żądanie, a nie w allowliście transportowej. §10.3 dokumentuje `GET /mcp/status` jako osobny adres stanu — wraz z uzasadnieniem, dlaczego NIE wpinamy go w /health/ (sonda Dockera; 503 restartowałoby cały appserver mimo awarii izolowanej do MCP). §12 dostaje kryteria na 503, 421, politykę Rollbara i log audytowy. Newsfragment wspomina /mcp/status. Osobnego newsfragmentu na same poprawki nie zakładam — naprawiają funkcję, która jeszcze nie wyszła. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU
Usterka 1 (Ważne) — awaria bazy w bramce uczelni (host_rozstrzyga_uczelnie) nie miała żadnej obsługi błędu: django.db.Error wychodził poza aplikację ASGI jako gołe 500 bez treści i bez zgłoszenia do Rollbara (middleware Django nie jest na tej ścieżce). Dodano try/except na django.db.Error (celowo wąski, nie except Exception), kontrolowane 503 przez istniejące _niedostepny(send) i zgłoszenie do Rollbara — z rate-limitem (raz na epizod awarii, reset przy powodzeniu), żeby nie odtworzyć B2 (spam Rollbara przy leżącej bazie). Trzy nowe testy w test_bramka_uczelni.py, zweryfikowane mutacyjnie (usunięcie try/except → testy padają na wyciekającym OperationalError, dokładnie jak opisał recenzent). Usterka 2 (jakość testu) — test_zle_host_daje_421 nie brał fixture uczelnia, więc baza była pusta i 421 zapadało w naszej bramce uczelni, zanim żądanie doszło do SDK. Test teraz używa hosta "testserver" (ma swój Site z fixture uczelnia, przechodzi naszą bramkę) spoza ALLOWED_HOSTS (odrzucane przez TransportSecuritySettings SDK). Zweryfikowane mutacyjnie: przy wymuszonym _dozwolone_hosty() == [] test teraz faktycznie pada (406 z prawdziwej aplikacji MCP, nie 421/403). uv run pytest src/mcp_server/ src/oauth_mcp/ src/api_v1/ src/django_bpp/tests/ -q → 479 passed, 1 skipped (baseline 476/1 + 3 nowe testy). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU
…ństwa Usterka 1: usuń gałąź "BppError 5xx jednak raportuj" w _z_raportowaniem (aplikacja.py). Działała odwrotnie do zamierzenia — DjangoQL 503 (statement_timeout) raportowane przy każdym wywołaniu, prawdziwe 500 z retry_5xx=True nigdy (gubi status_code w BppNetworkError). BppError teraz nigdy nie idzie do Rollbara; realne 500 i tak łapie CustomRollbarNotifierMiddleware na żądaniu wewnętrznym. Test przepisany tak, żeby przechodził przez prawdziwy BppClientInProcess (mockowany transport), nie przez wstrzyknięty wyjątek. Usterka 2: trzy ścieżki wyjątku poza aplikację ASGI w routing.py. 2a: _dane() dekodował nagłówki bez errors="replace" — niepoprawny UTF-8 w Authorization dawał UnicodeDecodeError przed jakąkolwiek siatką. 2c: except BladBazy w _obsluz był za wąski — CACHEOPS na sites.site/ bpp.uczelnia bez CACHEOPS_DEGRADE_ON_FAILURE daje przy leżącym Redisie redis.exceptions.ConnectionError, nie django.db.Error; poszerzono na except (BladBazy, BladRedisa). Dodano jedną siatkę bezpieczeństwa w RouterHttp.__call__ (except Exception: log + rollbar.report_exc_info + kontrolowane 503), która łapie także 2b (awaria bazy w BramkaBearera, bez własnego except) i wszystko inne nieoczekiwane, bez duplikowania raportowania z węższych except-ów. Nowe/zmienione testy w test_rollbar.py, test_routing.py, test_bramka_uczelni.py — każda poprawka zweryfikowana mutacyjnie. Raport: .superpowers/sdd/2026-09-06-mcp-hostowany-w-serwisie/ poprawki-self-review-report.md (gitignored, lokalny). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Po co
Dostęp AI do danych BPP idzie dziś przez
bpp-mcp— pakiet z PyPI, który użytkownik instaluje do swojego klienta jako serwer stdio. Wymaga to Pythona,uvxi — przede wszystkim — znajomości adresu własnej instancji (BPP_BASE_URLcelowo nie ma wartości domyślnej, bo każde wdrożenie to inna uczelnia). Klienci webowi i urządzenia mobilne nie mają jak uruchomić procesu lokalnego, więc są odcięte całkowicie.Ten PR sprawia, że BPP wystawia własny endpoint MCP, wyjeżdżający z tym samym obrazem dockerowym co reszta serwisu. Użytkownik wkleja adres swojej instancji z dopiskiem
/mcpi to jest cała konfiguracja.Spec:
docs/superpowers/specs/2026-09-05-mcp-hostowany-w-serwisie-design.mdPlan wdrożenia:
docs/superpowers/plans/2026-09-06-mcp-hostowany-w-serwisie.mdCo powstaje
Dwa adresy o różnym przeznaczeniu:
POST /mcpPOST /mcp/authinitializeGET /mcp/GET /mcp/statusDwa adresy zamiast jednego, bo SDK nie zna trybu „anonim dozwolony, zły token odrzucony" —
RequireAuthMiddlewaremontuje się tylko ztoken_verifieri wtedy odrzuca każde żądanie bez tożsamości. Rozdzielenie usuwa też zależność od nieznanego zachowania klientów przy 401 na późniejszym żądaniu.Jak to działa
Narzędzia nie są przepisywane. Jedenaście narzędzi i prompt przyjeżdżają z pakietu
bpp-mcp0.4.0 i są wołane bez zmian — dane pobierane są przez wywołanie własnej aplikacji Django w procesie (httpx.ASGITransport), nie po sieci. Dzięki temu uprawnienia, filtry, serializery i wyłączniki API działają, bo to fizycznie ten sam kod, który obsługuje zwykłych użytkowników.Cztery decyzje, które warto znać przy czytaniu
Klucz
"lifespan"zamiast nowego routera.ProtocolTypeRouternie „nie obsługuje" lifespanu — po prostu nie ma dla niego klucza i rzucaValueError, który uvicorn loguje jako „appears unsupported" i ignoruje. Menedżer sesji MCP nigdy wtedy nie wstaje. Gałąź WebSocketów zostaje nietknięta bit w bit.Start w osobnym zadaniu, plus leniwie. Wejście w
session_manager.run()z zadania żądania czyni je host taskiem grupy anyio — a wtedy aktywny w tym zadaniu cancel scope psuje je i wszystkie kolejne. Start idzie więc w dedykowane zadanie tła. Leniwie, bo Daphne nie implementuje protokołu lifespan, a to Daphne obsługujemanage.py runserver(czylirun-site run) ichannels_live_server.Klient per żądanie.
BppClientzapamiętuje adres bazowy w__init__, aConfigjestfrozen— więc jeden współdzielony klient nie mógłby obsłużyć wielu domen. Lifespan oddaje obiekt, któregoclientjest property; korzysta z furtki w udokumentowanym kontrakcieregister_tools. Przy okazji izoluje cache i sprowadza semafor do zasięgu jednego wywołania.Bramka uczelni, fail-closed.
Uczelniarozstrzyga się z nagłówkaHost, a przyUczelnia=NoneBramkaApiV1przepuszcza bezwarunkowo — omijając wszystkie sześć przełączników API — iUkryjStatusyKorektyMixinprzestaje filtrować ukryte statusy. Żądanie z hostem nierozstrzygalnym na uczelnię dostaje więc 421, zanim dotknie danych.Bezpieczeństwo
Bearer.Cookieuwierzytelniłoby sesją przezSessionAuthentication, aBasichasłem — aApiReadOnlyForBearerMiddlewaresprawdza tylko prefiksbearer, więc dla Basica warstwa read-only nie istnieje. Ochrona jest strukturalna: żądanie wewnętrzne jest budowane od zera, nie przepuszczane z filtrowaniem.Host, schemat i IP są propagowane z żądania zewnętrznego. Schemat czytany zsettings.SECURE_PROXY_SSL_HEADER(nie zaszyty —base.pyiproduction.pydeklarują różne nazwy).ASGITransportwpisałby127.0.0.1i wszyscy anonimowi dzieliliby jeden kubełek./api/v1/i bieżącego hosta — paginacyjnenextz DRF niosą URL-e bezwzględne.BppErrorto normalny kanał komunikatów — raportowanie go dawało 4 zgłoszenia na 4 wywołania i pozwalało wyczerpać kwotęcurl-em w pętli.Testy
479 passed, 1 skipped (
mcp_server+oauth_mcp+api_v1+django_bpp/tests), z czego 71 w samymmcp_server.Testy regresyjne są przypięte do konkretnych, nazwanych dziur — m.in.:
/mcp/authfaktycznie dociera do aplikacji (SDK montuje trasę jakoRoute, nieMount, więc bez przepisania ścieżki byłoby 404); budżet czasu zatrzymuje pracę, a nie tylko przestaje na nią czekać; start pod aktywnym cancel scope nie psuje kolejnych żądań; token dociera do ContextVara pakietu i nie wycieka do następnego żądania;Cookie/Basicnie trafiają do żądania wewnętrznego; dwa hosty widzą swoje dane; wyłączone API odmawia także przez/mcp; montaż wasgi.pyma osobny test subprocesowy.Wiele z nich zostało zweryfikowanych mutacyjnie — sprawdzono, że padają przy usuniętym mechanizmie, który mają chronić.
Co zostaje do zrobienia poza tym repo
bpp-deploywymaga dwóch zmian, zanim/mcpzadziała na produkcji:^/(bpp|api/v1)/zapytanie/; ciało JSON-RPC z DjangoQL ma duże szanse na 403 z WAF-a.limit_req—/mcpwpada podlocation /; do zmierzenia.Do tego: ręczne podłączenie prawdziwego klienta Claude do obu adresów i pełny taniec OAuth.
Zależności
Dochodzi
bpp-mcp>=0.4,<0.5, co ciągnie 21 pakietów (nie dziesięć, jak szacował pierwotnie spec — policzone po fakcie): m.in.mcp2.x,starlette,pydanticzpydantic-core,rpds-py(oba to rozszerzenia Rust) oraz dwa równoległe stosy HTTP —httpxdlabpp-mcpihttpx2dlamcp2.x. Warto zmierzyć wpływ na rozmiar obrazu i na bramkę Trivy; przejściebpp-mcpnahttpx2(wydanie 0.5.0) usunęłoby dublet.Zasięg — uczciwie
Allowlista redirect_uri w DCR dopuszcza wyłącznie
claude.ai,claude.comi localhost, więc logowanie działa w ekosystemie Claude; ChatGPT i Cursor nie zarejestrują klienta. Rozszerzenie allowlisty to osobna decyzja bezpieczeństwa, poza zakresem. Dostęp anonimowy nie wymaga DCR w ogóle, więc publiczna część działa z każdym klientem obsługującym zdalne MCP.🤖 Generated with Claude Code
https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU