Skip to content

feat(mcp_server): hostowany serwer MCP w serwisie (/mcp, /mcp/auth) - #804

Open
mpasternak wants to merge 29 commits into
devfrom
feat/mcp-hostowany-w-serwisie
Open

feat(mcp_server): hostowany serwer MCP w serwisie (/mcp, /mcp/auth)#804
mpasternak wants to merge 29 commits into
devfrom
feat/mcp-hostowany-w-serwisie

Conversation

@mpasternak

Copy link
Copy Markdown
Member

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, uvx i — przede wszystkim — znajomości adresu własnej instancji (BPP_BASE_URL celowo 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 /mcp i to jest cała konfiguracja.

Spec: docs/superpowers/specs/2026-09-05-mcp-hostowany-w-serwisie-design.md
Plan wdrożenia: docs/superpowers/plans/2026-09-06-mcp-hostowany-w-serwisie.md

Co powstaje

Dwa adresy o różnym przeznaczeniu:

Adres Zachowanie
POST /mcp publiczny, bez logowania — widzi to samo, co publiczna część bibliografii
POST /mcp/auth zawsze 401 bez tokenu, więc taniec OAuth startuje przy initialize
GET /mcp/ strona HTML z instrukcją podłączenia, adresy per host
GET /mcp/status stan endpointu dla monitoringu

Dwa adresy zamiast jednego, bo SDK nie zna trybu „anonim dozwolony, zły token odrzucony" — RequireAuthMiddleware montuje się tylko z token_verifier i 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

KLIENT AI  ──HTTPS──►  nginx ──►  gunicorn+uvicorn
                                       │
                          ProtocolTypeRouter (+1 klucz)
                    ┌──────────────────┼──────────────────┐
              "lifespan"            "http"           "websocket"
                    │                  │                  │
              LifespanMcp          RouterHttp        BEZ ZMIAN
                    │            ┌─────┴─────┐
                    │      /mcp*          reszta
                    │            │            │
                    │   bramka uczelni       │
                    │   bramka bearera       │
                    │   kontekst żądania     │
                    │            │            │
                    └── start ─► aplikacja MCP
                                 │            │
                          narzędzia z bpp-mcp │
                                 │            │
                    BppClient (per żądanie)   │
                          ASGITransport       │
                                 └────────────┴──► django_asgi_app

Narzędzia nie są przepisywane. Jedenaście narzędzi i prompt przyjeżdżają z pakietu bpp-mcp 0.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. ProtocolTypeRouter nie „nie obsługuje" lifespanu — po prostu nie ma dla niego klucza i rzuca ValueError, 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ługuje manage.py runserver (czyli run-site run) i channels_live_server.

Klient per żądanie. BppClient zapamiętuje adres bazowy w __init__, a Config jest frozen — więc jeden współdzielony klient nie mógłby obsłużyć wielu domen. Lifespan oddaje obiekt, którego client jest property; korzysta z furtki w udokumentowanym kontrakcie register_tools. Przy okazji izoluje cache i sprowadza semafor do zasięgu jednego wywołania.

Bramka uczelni, fail-closed. Uczelnia rozstrzyga się z nagłówka Host, a przy Uczelnia=None BramkaApiV1 przepuszcza bezwarunkowo — omijając wszystkie sześć przełączników API — i UkryjStatusyKorektyMixin przestaje filtrować ukryte statusy. Żądanie z hostem nierozstrzygalnym na uczelnię dostaje więc 421, zanim dotknie danych.

Bezpieczeństwo

  • Do żądania wewnętrznego trafia wyłącznie Bearer. Cookie uwierzytelniłoby sesją przez SessionAuthentication, a Basic hasłem — a ApiReadOnlyForBearerMiddleware sprawdza tylko prefiks bearer , 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 z settings.SECURE_PROXY_SSL_HEADER (nie zaszyty — base.py i production.py deklarują różne nazwy).
  • Throttling dostaje prawdziwe IP; bez tego ASGITransport wpisałby 127.0.0.1 i wszyscy anonimowi dzieliliby jeden kubełek.
  • Zakres URL-i ograniczony do /api/v1/ i bieżącego hosta — paginacyjne next z DRF niosą URL-e bezwzględne.
  • Budżet czasu egzekwowany w kliencie, nie wokół aplikacji: w trybie bezstanowym handler biegnie w zadaniu grupy menedżera, więc anulowanie żądania odcięłoby tylko odpowiedź, a narzędzie biegłoby dalej.
  • Rollbar dostaje wyłącznie nieoczekiwane wyjątki. BppError to 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 samym mcp_server.

Testy regresyjne są przypięte do konkretnych, nazwanych dziur — m.in.: /mcp/auth faktycznie dociera do aplikacji (SDK montuje trasę jako Route, nie Mount, 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/Basic nie trafiają do żądania wewnętrznego; dwa hosty widzą swoje dane; wyłączone API odmawia także przez /mcp; montaż w asgi.py ma 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-deploy wymaga dwóch zmian, zanim /mcp zadziała na produkcji:

  1. ModSecurity/CRS — wyłączenie obejmuje dziś tylko ^/(bpp|api/v1)/zapytanie/; ciało JSON-RPC z DjangoQL ma duże szanse na 403 z WAF-a.
  2. Strefa limit_req/mcp wpada pod location /; 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. mcp 2.x, starlette, pydantic z pydantic-core, rpds-py (oba to rozszerzenia Rust) oraz dwa równoległe stosy HTTPhttpx dla bpp-mcp i httpx2 dla mcp 2.x. Warto zmierzyć wpływ na rozmiar obrazu i na bramkę Trivy; przejście bpp-mcp na httpx2 (wydanie 0.5.0) usunęłoby dublet.

Zasięg — uczciwie

Allowlista redirect_uri w DCR dopuszcza wyłącznie claude.ai, claude.com i 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

mpasternak and others added 29 commits September 5, 2026 22:29
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant