From 97397f5bb326445f08cf504e134a040454dffa5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sat, 5 Sep 2026 22:29:04 +0200 Subject: [PATCH 01/29] =?UTF-8?q?docs(spec):=20projekt=20hostowanego=20MCP?= =?UTF-8?q?=20w=20serwisie=20=E2=80=94=20WERSJA=20DO=20PRZEPISANIA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU --- ...6-09-05-mcp-hostowany-w-serwisie-design.md | 436 ++++++++++++++++++ 1 file changed, 436 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-05-mcp-hostowany-w-serwisie-design.md diff --git a/docs/superpowers/specs/2026-09-05-mcp-hostowany-w-serwisie-design.md b/docs/superpowers/specs/2026-09-05-mcp-hostowany-w-serwisie-design.md new file mode 100644 index 000000000..65d333d8d --- /dev/null +++ b/docs/superpowers/specs/2026-09-05-mcp-hostowany-w-serwisie-design.md @@ -0,0 +1,436 @@ +# Hostowany serwer MCP w serwisie BPP (`/mcp`) + +Data: 2026-09-05 +Status: projekt do zatwierdzenia +Poprzednik: `2026-07-11-mcp-oauth-authorization-design.md` (Faza 3 — warstwa +OAuth po stronie BPP; **zrealizowana**, `src/oauth_mcp/`) + +--- + +## 1. Cel i motywacja + +Dziś dostęp AI do danych BPP idzie przez `bpp-mcp` — pakiet z PyPI, który +użytkownik instaluje do swojego klienta MCP jako serwer stdio. Model działa, +ale ma trzy koszty: + +1. **Próg wejścia.** Użytkownik potrzebuje Pythona i `uvx`, a przede wszystkim + musi **znać i wpisać adres swojej instancji** (`BPP_BASE_URL` celowo nie ma + wartości domyślnej — każde wdrożenie to inna uczelnia). +2. **Odcięta kategoria klientów.** Klienci webowi (claude.ai, chatgpt.com) i + urządzenia mobilne **nie mają jak uruchomić procesu lokalnego**. Dla + użytkownika nietechnicznego przeglądarka bywa jedynym kontaktem z asystentem. +3. **Skos wersji.** N wersji pakietu × M wdrożeń BPP. Widać to już w kodzie: + `bpp_mcp.oauth_client.AuthMode` ma warianty `PASSTHROUGH`/`PROXY`, żeby + obsłużyć instancje z warstwą OAuth i bez niej, a `_konwencjonalne()` trzyma + hardkodowane ścieżki `/o/*` jako fallback. Każda zmiana w `/api/v1/` tę + gałąź rozbudowuje. + +**Cel:** BPP wystawia własny endpoint MCP pod `/mcp`, wyjeżdżający z tym samym +obrazem dockerowym co reszta serwisu. Użytkownik wkleja do klienta AI adres +swojej instancji z dopiskiem `/mcp` i to jest cała konfiguracja. + +**Nie-cel:** wygaszenie `bpp-mcp`. Pakiet zostaje dla trybu stdio i dla +wskazywania instancji deweloperskich; przestaje być ścieżką rekomendowaną +w pierwszej kolejności. + +--- + +## 2. Kontekst — rzeczywistość ustalona z kodu + +Ustalenia z lektury, nie z pamięci. Numery linii wg stanu na `dev` @ `31d3d4612`. + +### 2.1 Strona BPP — warstwa OAuth istnieje i jest kompletna + +`src/oauth_mcp/` realizuje pełny Authorization Server: + +| Element | Plik | +|---|---| +| `/o/authorize/`, `/o/token/`, `/o/revoke_token/` (DOT, cherry-pick) | `urls.py` | +| DCR `/o/register/` (RFC 7591) + allowlista redirect_uri + rate-limit | `views_dcr.py` | +| AS metadata `/.well-known/oauth-authorization-server` (RFC 8414) | `views_metadata.py` | +| `StrictOAuth2Authentication` — bearer → `request.user`, twarde 401 | `authentication.py` | +| `ApiReadOnlyForBearerMiddleware` — mutacje `/api/v1/` bearerem → 403 | `middleware.py` | +| `/api/v1/whoami/` — preflight tożsamości | `views_whoami.py` | +| revoke przy zmianie hasła / dezaktywacji | `signals.py` | +| sprzątanie osieroconych rejestracji DCR (celery, 1:45) | `tasks.py` | + +`StrictOAuth2Authentication` jest **pierwszą** klasą w +`REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"]` (`base.py:1056`), przed +`SessionAuthentication` i `BasicAuthentication`. + +### 2.2 Strona `bpp-mcp` — szew wstrzykiwania już istnieje + +- Siedem narzędzi w `bpp_mcp/tools.py`; **każde przyjmuje `client: BppClient` + jako pierwszy argument**. +- `BppClient` (`client.py`) ma dokładnie dwie metody dostępu do danych: + `get_json()` i `get_paginated()`. Reszta klasy — budowanie URL-i, biała lista + cache'owalnych prefiksów, auto-follow paginacji, mapowanie 404 → + `BppNotFound` — jest **niezależna od transportu**. +- `httpx.AsyncClient` jest konstruowany **na sztywno** w `BppClient.__init__` + (bez parametru `transport`). To jedyna przeszkoda techniczna. +- Tryb HTTP już istnieje: `server.py` obsługuje `--http` → + `run(transport="streamable-http")` z `AuthSettings` i `WhoamiTokenVerifier`. + +### 2.3 Infrastruktura + +- ASGI: `src/django_bpp/asgi.py` — `ProtocolTypeRouter` z + `{"http": django_asgi_app, "websocket": ...}`. +- Produkcja: `gunicorn django_bpp.asgi:application` z `UvicornWorker` + i **recyklingiem `--max-requests`** (`docker/appserver/entrypoint-appserver.sh:104`). +- nginx (`bpp-deploy`): `proxy_*_timeout` 60/300/300 s, strefy `limit_req` + na `location /`. +- `api_v1.pagination.BppLimitOffsetPagination` **ma** twardy `max_limit` + (`base.py:1053`). + +--- + +## 3. Decyzje architektoniczne + +| # | Decyzja | Uzasadnienie | +|---|---|---| +| D1 | Kierunek zależności: **BPP zależy od `bpp-mcp`** | Jedna implementacja narzędzi, zero rozjazdu. Wybór użytkownika. | +| D2 | Montaż: **dyspozytor w `asgi.py`** (wariant A) | Punkt wpięcia istnieje; zero zmian w `bpp-deploy`. | +| D3 | **`stateless_http=True`** | Wymuszone przez recykling workera gunicorna — stateful sesja ginie z procesem w środku pracy użytkownika. | +| D4 | Dostęp do danych: **`httpx.ASGITransport`** na `django_asgi_app` | Ta sama ścieżka kodu co przez sieć, bez gniazda. `BppClient` bez zmian. | +| D5 | Polityka dostępu: **dziedziczona z `/api/v1/`** | Anonimowo do odczytu, bearer odblokowuje resztę. Nie wprowadzamy nowej polityki. | +| D6 | **Read-only** w pierwszej wersji | Zgodnie z `middleware.py`; narzędzia mutujące poza zakresem. | + +### 3.1 Dlaczego `ASGITransport`, a nie `resolve()` + syntetyczny request + +Rozważany był wariant „nadpisz `BppClient._request`, rozwiąż URL w URLconfie, +zbuduj syntetyczny request, zawołaj widok DRF". Odrzucony na rzecz D4: + +| | `resolve()` + syntetyczny request | `ASGITransport` | +|---|---|---| +| Zmiany w `BppClient` | nadpisanie `_request` | **żadne** poza szwem `transport=` | +| Middleware Django | **nie biegnie** | biegnie w całości | +| Most sync↔async | do rozwiązania po naszej stronie | **robi handler ASGI Django** | +| „ta sama ścieżka kodu" | w przybliżeniu | dosłownie | + +Wzorzec jest ustalony w ekosystemie: `fastapi-mcp` używa `ASGITransport` +**domyślnie** — komunikuje się z aplikacją bez żądań HTTP, nie wymaga +`base_url`, a serwer nie musi nawet działać. + +--- + +## 4. Architektura + +``` + gunicorn + UvicornWorker + │ + django_bpp.asgi:application + │ + ┌────────────┴────────────┐ + ścieżka /mcp* wszystko inne + │ │ + aplikacja FastMCP django_asgi_app + (stateless streamable HTTP) ▲ + │ │ + narzędzia z bpp_mcp.tools │ + │ │ + BppClient(transport=ASGITransport(──┘) +``` + +Klucz: `ASGITransport` dostaje **`django_asgi_app`**, nie `application`. +`application` to `ProtocolTypeRouter` zawierający routing na `/mcp` — +podanie go groziłoby rekurencją. + +### 4.1 Nowa aplikacja Django: `src/mcp_server/` + +Zgodnie z konwencją repo (aplikacja per obszar funkcjonalny): + +``` +src/mcp_server/ + __init__.py + apps.py + asgi_app.py # budowa aplikacji FastMCP + montaż narzędzi + client.py # BppClientInProcess (podklasa z bpp_mcp) + auth.py # rozstrzyganie bearera na wejściu /mcp + views.py # strona /mcp/ dla człowieka (HTML) + urls.py # /mcp/ (HTML) — endpoint protokołu jest w asgi.py + templates/mcp_server/index.html + tests/ +``` + +Warstwa OAuth **zostaje w `oauth_mcp`** — `mcp_server` z niej korzysta, +nie duplikuje. Nowy widok RFC 9728 trafia do `oauth_mcp/views_metadata.py`, +obok istniejącego RFC 8414 (patrz §6.1). + +--- + +## 5. Dostęp do danych — `BppClientInProcess` + +```python +# src/mcp_server/client.py +import httpx +from bpp_mcp.client import BppClient +from django_bpp.asgi import django_asgi_app + + +class BppClientInProcess(BppClient): + """BppClient wołający własną aplikację Django bez gniazda TCP.""" + + def __init__(self, config, **kwargs): + super().__init__( + config, + transport=httpx.ASGITransport(app=django_asgi_app), + **kwargs, + ) +``` + +Odziedziczona mechanika — co żyje, a co umiera: + +| Element `BppClient` | W procesie | +|---|---| +| retry ×2 + backoff | **martwy** — brak sieci do ponowienia; `max_retries=0` | +| semafor współbieżności (8) | **zostaje** — dławi równoległą pracę bazy | +| cache prefiksów słownikowych | **zostaje** bez zmian | +| auto-follow paginacji | **zostaje** — `next` z DRF jest bezwzględny, `_full_url` to obsługuje | +| mapowanie błędów (404 → `BppNotFound`) | **zostaje** — te same kody statusu | + +`config.api_root` wskazuje na `http:///api/v1` — host jest +nieużywany przy `ASGITransport`, ale musi być syntaktycznie poprawny, bo +`httpx` buduje z niego `URL`. Ustalamy `http://bpp.invalid/api/v1`, żeby +przypadkowe wyjście do sieci **nie mogło się udać po cichu**. + +### 5.1 Zmiana wymagana w `bpp-mcp` (drugie repo) + +`BppClient.__init__` musi przyjąć opcjonalny `transport` i przekazać go do +`httpx.AsyncClient`. Jedna linia sygnatury + jedna w konstruktorze; domyślne +zachowanie (`transport=None`) bez zmian, więc niekompatybilności nie ma. + +Wymaga wydania `bpp-mcp` przed wydaniem BPP. Kolejność w planie: +PR w `bpp-mcp` → release na PyPI → pin w `pyproject.toml` BPP. + +--- + +## 6. Uwierzytelnianie i polityka dostępu + +### 6.1 Przepływ na wejściu `/mcp` + +``` +brak nagłówka Authorization → przepuść bez niego → AnonymousUser + → dane publiczne (polityka /api/v1/) +ważny bearer → przepuść nagłówek dalej + → DRF ustala tożsamość StrictOAuth2Authentication +nieważny / wygasły / bez scope → 401 + WWW-Authenticate +``` + +Nagłówek `WWW-Authenticate` przy 401: + +``` +Bearer resource_metadata="https:///.well-known/oauth-protected-resource" +``` + +Nowy widok w `oauth_mcp/views_metadata.py` (RFC 9728), bliźniak istniejącego +RFC 8414 — ten sam wzorzec `build_absolute_uri` dla wielo-domenowości: + +```json +{ + "resource": "https:///mcp", + "authorization_servers": ["https://"], + "scopes_supported": ["read"], + "bearer_methods_supported": ["header"] +} +``` + +Spec z 2026-07-11 §6 przypisywał ten plik pakietowi `bpp-mcp`. **Przenosi się +do BPP** — Resource Serverem jest teraz instancja. + +### 6.2 Jedno źródło prawdy o ważności tokenu + +Reguły „co znaczy ważny token MCP" żyją dziś w dwóch miejscach: + +- `authentication.py` — `is_active` + wymagany scope `read` + twarde 401, +- `middleware.py::_ma_wazny_bearer` — `AccessToken.is_valid()`. + +Warstwa MCP byłaby trzecim. **Wyciągamy je do `oauth_mcp/tokens.py`** +i konsumujemy w trzech miejscach. To nie refaktor na boku — bez tego +„ważny token" znaczy trzy różne rzeczy, a rozjazd jest kwestią czasu. + +Sygnatura: + +```python +def zweryfikuj_bearer(raw: str) -> tuple[User, AccessToken]: + """Podnosi BearerNiewazny gdy token nie istnieje, wygasł, konto jest + nieaktywne albo brakuje scope 'read'.""" +``` + +`StrictOAuth2Authentication` zachowuje integrację z DRF (dziedziczenie po +`OAuth2Authentication`), ale **reguły** po-sprawdzające deleguje do helpera. + +### 6.3 Warstwa MCP dekoduje token wyłącznie dla kodu HTTP + +Kuszące jest rozstrzygnąć token raz w warstwie MCP i podać widokowi gotowe +`request.user`. **Nie robimy tego** — powstałby drugi tor uwierzytelniania, +mogący rozjechać się ze `StrictOAuth2Authentication`, klasą o niebanalnych +regułach chronionych testami w `test_authentication.py`. + +Przekazujemy **surowy nagłówek** do wewnętrznego żądania; DRF uwierzytelnia +normalnie, tą samą klasą. Warstwa MCP dekoduje token tylko po to, by +zdecydować między 401 transportu a przepuszczeniem. + +### 6.4 `whoami` przestaje być potrzebne dla tej ścieżki + +`bpp-mcp` robi round-trip do `/api/v1/whoami/`, bo token jest opaque, a pakiet +stoi po drugiej stronie sieci. Tutaj weryfikacja to lookup w tej samej bazie. +Znika `WhoamiUnavailable` i rozróżnianie „token nieważny (401)" od „BPP +nieosiągalne (500)". **Endpoint `whoami` zostaje** — używa go nadal `bpp-mcp`. + +--- + +## 7. Bezpieczeństwo + +### 7.1 Przekazujemy WYŁĄCZNIE `Authorization` — nigdy `Cookie` + +`SessionAuthentication` jest w `DEFAULT_AUTHENTICATION_CLASSES` +(`base.py:1058`). Gdyby warstwa MCP przekazywała nagłówki hurtem, klient +wysyłający `Cookie:` do `/mcp` **uwierzytelniłby się sesją**, omijając cały +model OAuth — z pełnymi uprawnieniami zalogowanego użytkownika i bez zgody, +bez revoke, bez scope. + +Allowlista nagłówków wewnętrznego żądania: `Authorization`, `Accept`. +Wszystko inne (`Cookie`, `X-Forwarded-*`, `Referer`) **odcinamy jawnie**. +Test regresyjny obowiązkowy. + +### 7.2 Read-only + +`ApiReadOnlyForBearerMiddleware` biegnie w wewnętrznym żądaniu (bo idzie ono +przez pełny stos Django) — czyli mutacja bearerem dostanie 403 tak samo jak +przez sieć. Niezależnie od tego narzędzia MCP wykonują **wyłącznie GET**. +Dwie warstwy, jak w spec §5.4 z 2026-07-11. + +### 7.3 Rekurencja + +`ASGITransport` dostaje `django_asgi_app`, nie `application`. Dodatkowo +zabezpieczenie: `BppClientInProcess` odrzuca ścieżki spoza `/api/v1/`. + +### 7.4 Otwarte DCR na domenie produkcyjnej + +Bez zmian względem stanu obecnego — `/o/register/` już jest publiczne, ma +rate-limit i sprzątanie osieroconych rejestracji. `/mcp` nie pogarsza sytuacji, +ale **zwiększa ruch na tej ścieżce**, więc rate-limit trzeba obserwować. + +--- + +## 8. Discovery i strona dla człowieka + +Pod `/mcp` mieszkają dwie rzeczy o różnych odbiorcach: + +- **`POST /mcp`** — protokół MCP (JSON-RPC over streamable HTTP), obsługiwany + przez aplikację FastMCP w `asgi.py`. +- **`GET /mcp/`** — strona HTML dla człowieka, widok Django w `mcp_server`. + +Rozdzielenie po metodzie i ukośniku jest jednoznaczne: klienci MCP wołają +`POST` na `/mcp`, przeglądarka `GET` na `/mcp/`. + +Treść strony (wzorzec: `mcp.sentry.dev`): + +- adres złącza jako główna treść: `https:///mcp` +- jedno zdanie o dostępie: publiczny odczyt bez logowania; token odblokowuje + dane niepubliczne +- gotowe wklejki per klient: `claude mcp add`, JSON dla Cursora, kroki VS Code +- link do `bpp-mcp` dla trybu stdio + +Strona korzysta z `request.build_absolute_uri`, więc każda uczelnia widzi +**swój** adres bez konfiguracji. + +--- + +## 9. Wdrożenie + +Nic do zrobienia w `bpp-deploy` — `/mcp` jedzie pod istniejącym +`location /`. Dwie rzeczy do obserwacji, nie do zmiany na wejściu: + +1. **`limit_req` z `location /`** obejmie `/mcp`. Agent robiący kilkanaście + wywołań narzędzi pod rząd może się o to obić. Do zmierzenia; jeśli boli — + własna strefa w `_bpp-locations.conf`. +2. **Pula wątków workera.** Zewnętrzne żądanie `/mcp` trzyma worker, a + wewnętrzne wywołania DRF idą do puli wątków tego samego procesu. + Zakleszczenia nie ma (widoki DRF są synchroniczne, więc handler ASGI + odsyła je do wątków), ale rozmiar puli przy równoległym rozwijaniu + hyperlinków w `pobierz_rekord` **wymaga pomiaru**, nie założenia. + +`stateless_http=True` sprawia, że recykling `--max-requests` jest nieszkodliwy +i że nie ma potrzeby sticky sessions. `proxy_read_timeout 300s` nie jest +problemem, bo nie utrzymujemy długich strumieni. + +--- + +## 10. Testy + +Poziomy, zgodnie z konwencją repo (pytest, bez `unittest.TestCase`, +`model_bakery.baker`): + +**Jednostkowe (`src/mcp_server/tests/`)** +- `BppClientInProcess` zwraca dla `/api/v1/publikacje/` to samo, co klient + sieciowy dla tej samej ścieżki (test na `ASGITransport`, `@pytest.mark.django_db`) +- allowlista nagłówków: `Cookie` przekazany do `/mcp` **nie** uwierzytelnia + (§7.1) — test regresyjny +- ścieżka spoza `/api/v1/` odrzucona (§7.3) +- anonim dostaje dane publiczne; nieważny bearer → 401 + `WWW-Authenticate` +- ważny bearer → tożsamość z `request.user` w wewnętrznym żądaniu + +**`oauth_mcp`** +- `oauth-protected-resource` zwraca poprawne URL-e przy różnych hostach + (wielo-domenowość, jak istniejący `test_metadata.py`) +- `zweryfikuj_bearer` — parytet z dotychczasowym zachowaniem + `StrictOAuth2Authentication` i `_ma_wazny_bearer` (testy istniejące muszą + przejść bez zmian) + +**Protokół** +- `initialize` → `tools/list` → `tools/call` na żywej aplikacji ASGI + (klient MCP z SDK, bez sieci) + +**Ręcznie, do odnotowania w planie** +- podłączenie prawdziwego klienta (Claude) i przejście tańca OAuth end-to-end + +--- + +## 11. Poza zakresem + +- Narzędzia mutujące (zapis/import przez MCP). +- WebMCP jako API przeglądarki (`navigator.modelContext`) — osobny projekt. +- Wygaszenie `bpp-mcp`. +- Zasoby (`resources`) i prompty MCP — tylko `tools` w pierwszej wersji. +- Własna strefa `limit_req` w nginksie (dopiero po pomiarze, §9). +- RFC 8707 / audience binding — dziedziczone odstępstwo z poprzedniego specu. + +--- + +## 12. Otwarte kwestie do rozstrzygnięcia w planie + +1. **Czy klienci MCP odpalają OAuth przy 401 na kolejnym żądaniu, czy tylko + przy `initialize`?** Jeśli tylko przy `initialize`, endpoint anonimowy nigdy + nie zaproponuje logowania i potrzebny jest drugi adres (np. `/mcp/auth`) dla + pełnego dostępu. **Do weryfikacji empirycznej na żywym kliencie**, nie z + lektury specyfikacji. Częściowo łagodzi to istniejący mechanizm hybrydowej + podpowiedzi logowania w treści odpowiedzi narzędzi + (`BppClient.transport` steruje nim dla DjangoQL). +2. Rozmiar puli wątków i zachowanie pod obciążeniem (§9.2). +3. Czy `limit_req` faktycznie boli (§9.1). +4. Wersja `bpp-mcp` do przypięcia po wydaniu szwu `transport=` (§5.1). +5. Czy `config.transport` w `BppClient` (dziś `"stdio"`/`"http"`) potrzebuje + trzeciej wartości `"in-process"` — steruje hybrydową podpowiedzią logowania, + która w tym trybie powinna brzmieć inaczej niż w stdio. +6. Stały komentarz w `bpp_mcp/client.py` twierdzi, że „BPP nie deklaruje + `max_limit` po stronie DRF" — jest nieaktualny (`BppLimitOffsetPagination` + ma twardy cap). Poprawić przy okazji PR-a w `bpp-mcp`. + +--- + +## 13. Kryteria akceptacji + +- `POST /mcp` odpowiada na `initialize`, `tools/list`, `tools/call` bez tokenu, + zwracając dane publiczne identyczne z `/api/v1/`. +- Ważny bearer → wywołanie narzędzia widzi tożsamość użytkownika. +- Nieważny bearer → **401** z `WWW-Authenticate` wskazującym + `oauth-protected-resource`. +- `Cookie` wysłany do `/mcp` **nie** uwierzytelnia (§7.1). +- `/.well-known/oauth-protected-resource` poprawne przy wielu hostach. +- `GET /mcp/` renderuje stronę z adresem złącza właściwym dla hosta. +- Narzędzia pochodzą z `bpp_mcp.tools` — **żadnej kopii** w `src/`. +- `stateless_http=True`; brak długożyjących strumieni. +- Testy zielone; `ruff format` + `ruff check` czyste; `pre-commit` bez uwag. +- Newsfragment w `src/bpp/newsfragments/`. +- Istniejące testy `oauth_mcp` przechodzą **bez modyfikacji**. From 1b24356fbccb8b78bdad3003b1a64877d8488aba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sun, 6 Sep 2026 11:00:24 +0200 Subject: [PATCH 02/29] docs(spec): przepisz projekt hostowanego MCP po recenzji i probe'ach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU --- ...6-09-05-mcp-hostowany-w-serwisie-design.md | 710 ++++++++++-------- 1 file changed, 406 insertions(+), 304 deletions(-) diff --git a/docs/superpowers/specs/2026-09-05-mcp-hostowany-w-serwisie-design.md b/docs/superpowers/specs/2026-09-05-mcp-hostowany-w-serwisie-design.md index 65d333d8d..10a4a8b44 100644 --- a/docs/superpowers/specs/2026-09-05-mcp-hostowany-w-serwisie-design.md +++ b/docs/superpowers/specs/2026-09-05-mcp-hostowany-w-serwisie-design.md @@ -1,436 +1,538 @@ -# Hostowany serwer MCP w serwisie BPP (`/mcp`) +# Hostowany serwer MCP w serwisie BPP (`/mcp`) — projekt 1 z 3 -Data: 2026-09-05 +Data: 2026-09-05 (przepisany 2026-09-06) Status: projekt do zatwierdzenia -Poprzednik: `2026-07-11-mcp-oauth-authorization-design.md` (Faza 3 — warstwa -OAuth po stronie BPP; **zrealizowana**, `src/oauth_mcp/`) +Poprzednik: `2026-07-11-mcp-oauth-authorization-design.md` (warstwa OAuth po +stronie BPP — **zrealizowana**, `src/oauth_mcp/`) + +> **Ten dokument został przepisany.** Pierwsza wersja (commit poprzedzający) +> dostała od recenzji werdykt „wymaga przeprojektowania". Dwa blokery +> potwierdzono, jeden zdegradowano po weryfikacji empirycznej, cztery dziury +> bezpieczeństwa dopisano. Wszystkie zmiany są opisane w §14. --- ## 1. Cel i motywacja -Dziś dostęp AI do danych BPP idzie przez `bpp-mcp` — pakiet z PyPI, który -użytkownik instaluje do swojego klienta MCP jako serwer stdio. Model działa, -ale ma trzy koszty: +Dostęp AI do danych BPP idzie dziś przez `bpp-mcp` — pakiet z PyPI, który +użytkownik instaluje do swojego klienta MCP jako serwer stdio. Trzy koszty: -1. **Próg wejścia.** Użytkownik potrzebuje Pythona i `uvx`, a przede wszystkim - musi **znać i wpisać adres swojej instancji** (`BPP_BASE_URL` celowo nie ma +1. **Próg wejścia.** Potrzebny Python i `uvx`, a przede wszystkim użytkownik + musi **znać i wpisać adres swojej instancji** (`BPP_BASE_URL` celowo bez wartości domyślnej — każde wdrożenie to inna uczelnia). -2. **Odcięta kategoria klientów.** Klienci webowi (claude.ai, chatgpt.com) i - urządzenia mobilne **nie mają jak uruchomić procesu lokalnego**. Dla - użytkownika nietechnicznego przeglądarka bywa jedynym kontaktem z asystentem. -3. **Skos wersji.** N wersji pakietu × M wdrożeń BPP. Widać to już w kodzie: - `bpp_mcp.oauth_client.AuthMode` ma warianty `PASSTHROUGH`/`PROXY`, żeby - obsłużyć instancje z warstwą OAuth i bez niej, a `_konwencjonalne()` trzyma - hardkodowane ścieżki `/o/*` jako fallback. Każda zmiana w `/api/v1/` tę - gałąź rozbudowuje. +2. **Odcięta kategoria klientów.** Klienci webowi (claude.ai) i urządzenia + mobilne **nie mają jak uruchomić procesu lokalnego**. +3. **Skos wersji.** N wersji pakietu × M wdrożeń BPP. **Cel:** BPP wystawia własny endpoint MCP pod `/mcp`, wyjeżdżający z tym samym -obrazem dockerowym co reszta serwisu. Użytkownik wkleja do klienta AI adres -swojej instancji z dopiskiem `/mcp` i to jest cała konfiguracja. +obrazem dockerowym co reszta serwisu. Użytkownik wkleja adres swojej instancji +z dopiskiem `/mcp` i to jest cała konfiguracja. -**Nie-cel:** wygaszenie `bpp-mcp`. Pakiet zostaje dla trybu stdio i dla -wskazywania instancji deweloperskich; przestaje być ścieżką rekomendowaną -w pierwszej kolejności. +### 1.1 Zasięg — uczciwie ---- +Pierwsza wersja tego specu obiecywała „claude.ai, ChatGPT, Cursor, VS Code". +**To była nieprawda.** Allowlista redirect_uri w DCR +(`src/oauth_mcp/views_dcr.py:17-23`) dopuszcza wyłącznie: + +``` +https://claude.ai/* https://*.claude.ai/* https://claude.com/* +http://localhost[:port]/* http://127.0.0.1[:port]/* +``` + +ChatGPT i Cursor **nie zarejestrują klienta** — dostaną odmowę na DCR. +Realny zasięg tej pracy to **ekosystem Claude** (web, desktop, Code) plus +klienci lokalne. Rozszerzenie allowlisty jest osobną decyzją bezpieczeństwa, +poza zakresem (§13). -## 2. Kontekst — rzeczywistość ustalona z kodu +Dostęp anonimowy nie wymaga DCR w ogóle, więc **publiczna część działa +z każdym klientem** obsługującym zdalne MCP. Ograniczenie dotyczy tylko +logowania. + +--- -Ustalenia z lektury, nie z pamięci. Numery linii wg stanu na `dev` @ `31d3d4612`. +## 2. Kontekst — ustalenia z kodu i z probe'ów -### 2.1 Strona BPP — warstwa OAuth istnieje i jest kompletna +Numery linii wg `dev` @ `31d3d4612`. Ustalenia oznaczone **[probe]** pochodzą +z uruchomionego kodu, nie z lektury. -`src/oauth_mcp/` realizuje pełny Authorization Server: +### 2.1 Warstwa OAuth po stronie BPP — istnieje i jest kompletna | Element | Plik | |---|---| -| `/o/authorize/`, `/o/token/`, `/o/revoke_token/` (DOT, cherry-pick) | `urls.py` | -| DCR `/o/register/` (RFC 7591) + allowlista redirect_uri + rate-limit | `views_dcr.py` | -| AS metadata `/.well-known/oauth-authorization-server` (RFC 8414) | `views_metadata.py` | -| `StrictOAuth2Authentication` — bearer → `request.user`, twarde 401 | `authentication.py` | -| `ApiReadOnlyForBearerMiddleware` — mutacje `/api/v1/` bearerem → 403 | `middleware.py` | -| `/api/v1/whoami/` — preflight tożsamości | `views_whoami.py` | -| revoke przy zmianie hasła / dezaktywacji | `signals.py` | -| sprzątanie osieroconych rejestracji DCR (celery, 1:45) | `tasks.py` | - -`StrictOAuth2Authentication` jest **pierwszą** klasą w -`REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"]` (`base.py:1056`), przed -`SessionAuthentication` i `BasicAuthentication`. - -### 2.2 Strona `bpp-mcp` — szew wstrzykiwania już istnieje - -- Siedem narzędzi w `bpp_mcp/tools.py`; **każde przyjmuje `client: BppClient` - jako pierwszy argument**. -- `BppClient` (`client.py`) ma dokładnie dwie metody dostępu do danych: - `get_json()` i `get_paginated()`. Reszta klasy — budowanie URL-i, biała lista - cache'owalnych prefiksów, auto-follow paginacji, mapowanie 404 → - `BppNotFound` — jest **niezależna od transportu**. -- `httpx.AsyncClient` jest konstruowany **na sztywno** w `BppClient.__init__` - (bez parametru `transport`). To jedyna przeszkoda techniczna. -- Tryb HTTP już istnieje: `server.py` obsługuje `--http` → - `run(transport="streamable-http")` z `AuthSettings` i `WhoamiTokenVerifier`. - -### 2.3 Infrastruktura +| `/o/authorize/`, `/o/token/`, `/o/revoke_token/` | `oauth_mcp/urls.py` | +| DCR `/o/register/` (RFC 7591) + allowlista + rate-limit | `oauth_mcp/views_dcr.py` | +| AS metadata `/.well-known/oauth-authorization-server` (RFC 8414) | `oauth_mcp/views_metadata.py` | +| `StrictOAuth2Authentication` — bearer → `request.user`, twarde 401 | `oauth_mcp/authentication.py` | +| `ApiReadOnlyForBearerMiddleware` — mutacje `/api/v1/` bearerem → 403 | `oauth_mcp/middleware.py` | +| `WhoAmIView` — **routing w `api_v1/urls.py:265`**, pod `z_bramka_api_v1` | `oauth_mcp/views_whoami.py` | +| revoke przy zmianie hasła / dezaktywacji | `oauth_mcp/signals.py` | + +`StrictOAuth2Authentication` jest **pierwsza** w +`REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"]` (`base.py:1057`), przed +`SessionAuthentication` (1058) i `BasicAuthentication` (1059). + +### 2.2 Pakiet `bpp-mcp` 0.4.0 — szwy gotowe + +Wydany dziś (projekt 2, PR-y #21–#23). Wystawia: + +- `register_tools(mcp)` — **11 narzędzi + 1 prompt** na instancji `MCPServer` + hosta. (Pierwsza wersja specu mówiła o siedmiu i twierdziła, że rejestrowane + są funkcje z `tools.py`; w rzeczywistości to wrappery z `server.py`, biorące + klienta z `ctx.request_context.lifespan_context`.) +- `KontekstApp` — kontrakt lifespanu (`client`, `bearer_provider`). +- `BppClient(transport=...)` — wstrzykiwany transport `httpx`. +- `TrybAuth.W_PROCESIE` — bearer albo anonim, **nigdy Basic**. +- `BppClient._slownik_cache()` — szew pod cache o zasięgu per-żądanie. + +### 2.3 MCP SDK 2.0 — ustalenia z probe'ów + +- **[probe]** `stateless_http` i `json_response` **nie są parametrami + konstruktora** `MCPServer` — mieszkają w `streamable_http_app()`. +- **[probe]** `POST /mcp` **bez wejścia w lifespan** kończy się + `RuntimeError: Task group is not initialized. Make sure to use run().` + Dotyczy również `stateless_http=True`. +- **[probe]** Po wejściu w lifespan aplikacji ta sama ścieżka oddaje **200** + z poprawną odpowiedzią `initialize`. +- **[probe]** Aplikacja MCP ma **własną allowlistę hostów** + (`TransportSecuritySettings`), niezależną od `ALLOWED_HOSTS` Django. Nieznany + `Host` → **421 Invalid Host header**. +- W SDK 2.0 **lifespan wchodzi RAZ na serwer**, nie per sesja — jego wynik + dzielą wszystkie sesje i żądania (docstring `bpp_mcp/server.py`). + +### 2.4 Infrastruktura - ASGI: `src/django_bpp/asgi.py` — `ProtocolTypeRouter` z - `{"http": django_asgi_app, "websocket": ...}`. -- Produkcja: `gunicorn django_bpp.asgi:application` z `UvicornWorker` - i **recyklingiem `--max-requests`** (`docker/appserver/entrypoint-appserver.sh:104`). -- nginx (`bpp-deploy`): `proxy_*_timeout` 60/300/300 s, strefy `limit_req` - na `location /`. -- `api_v1.pagination.BppLimitOffsetPagination` **ma** twardy `max_limit` - (`base.py:1053`). + `{"http": django_asgi_app, "websocket": ...}`. **`ProtocolTypeRouter` rzuca + `ValueError` na scope `lifespan`**, a uvicorn w trybie `auto` loguje to jako + „appears unsupported" i jedzie dalej — czyli błąd jest cichy. +- Produkcja: `gunicorn django_bpp.asgi:application` z `UvicornWorker`; + `max_requests=1000` + jitter 200 w `docker/appserver/gunicorn_conf.py` + (**nie** w entrypoincie). Gałąź dev to `uvicorn --reload` bez gunicorna. +- nginx (`bpp-deploy`): trzy strefy `limit_req` (`_bpp-locations.conf:74-89`) — + `/api/` 60 r/s burst 60, `/admin/` 50 r/s, `/` 100 r/s burst 100. +- **ModSecurity/CRS** przed appserverem; jedyne wyłączenie dla DjangoQL to + `^/(bpp|api/v1)/zapytanie/` (`modsecurity-override.conf.template:79`). +- `api_v1.pagination.BppLimitOffsetPagination` — `MAKS_LIMIT = 500`, **cichy + clamp pojedynczej strony** (nie odmowa). + +### 2.5 Bramka API i model tożsamości hosta + +To jest źródło najgroźniejszego problemu w §7.2. Kolejność w +`BramkaApiV1` (`src/api_v1/permissions.py`), cytat z docstringu: + +> 1. uczelnia nierozstrzygnięta (pusta baza, kreator konfiguracji, brak +> mapowania Site→Uczelnia) → **przepuszczamy**, bo nie ma kto podjąć decyzji + +`Uczelnia` rozstrzyga się z nagłówka `Host` (`SiteResolutionMiddleware`, +`Uczelnia.objects.get_for_request`). Czyli: **request z nieznanym hostem omija +wszystkie sześć przełączników API.** Do tego +`UkryjStatusyKorektyMixin.ukryte_statusy` daje wtedy `None` → rekordy ukrytych +statusów przestają być filtrowane. + +`SearchAnonThrottle` (`api_v1/throttling.py`) dziedziczy po `AnonRateThrottle`, +czyli **liczy po IP**. --- -## 3. Decyzje architektoniczne +## 3. Trzy projekty, nie jeden -| # | Decyzja | Uzasadnienie | +Recenzja pierwszej wersji słusznie wskazała, że dokument mieszał trzy +niezależne przedsięwzięcia. Rozbicie: + +| # | Projekt | Status | |---|---|---| -| D1 | Kierunek zależności: **BPP zależy od `bpp-mcp`** | Jedna implementacja narzędzi, zero rozjazdu. Wybór użytkownika. | -| D2 | Montaż: **dyspozytor w `asgi.py`** (wariant A) | Punkt wpięcia istnieje; zero zmian w `bpp-deploy`. | -| D3 | **`stateless_http=True`** | Wymuszone przez recykling workera gunicorna — stateful sesja ginie z procesem w środku pracy użytkownika. | -| D4 | Dostęp do danych: **`httpx.ASGITransport`** na `django_asgi_app` | Ta sama ścieżka kodu co przez sieć, bez gniazda. `BppClient` bez zmian. | -| D5 | Polityka dostępu: **dziedziczona z `/api/v1/`** | Anonimowo do odczytu, bearer odblokowuje resztę. Nie wprowadzamy nowej polityki. | -| D6 | **Read-only** w pierwszej wersji | Zgodnie z `middleware.py`; narzędzia mutujące poza zakresem. | +| 1 | **Hosting MCP w BPP** — dyspozytor, klient w procesie, discovery, strona | **ten spec** | +| 2 | Szwy w `bpp-mcp` | **zrobione** — 0.4.0 na PyPI | +| 3 | Konsolidacja reguł tokenu w `oauth_mcp/tokens.py` | osobny spec, §13 | -### 3.1 Dlaczego `ASGITransport`, a nie `resolve()` + syntetyczny request +Projekt 3 wypada z zakresu świadomie: to zmiana w **testowanej warstwie +uwierzytelniania**, i wrzucanie jej do środka feature'a było błędem. Ten spec +konsumuje istniejące `StrictOAuth2Authentication` bez ruszania go. -Rozważany był wariant „nadpisz `BppClient._request`, rozwiąż URL w URLconfie, -zbuduj syntetyczny request, zawołaj widok DRF". Odrzucony na rzecz D4: +--- -| | `resolve()` + syntetyczny request | `ASGITransport` | -|---|---|---| -| Zmiany w `BppClient` | nadpisanie `_request` | **żadne** poza szwem `transport=` | -| Middleware Django | **nie biegnie** | biegnie w całości | -| Most sync↔async | do rozwiązania po naszej stronie | **robi handler ASGI Django** | -| „ta sama ścieżka kodu" | w przybliżeniu | dosłownie | +## 4. Decyzje architektoniczne -Wzorzec jest ustalony w ekosystemie: `fastapi-mcp` używa `ASGITransport` -**domyślnie** — komunikuje się z aplikacją bez żądań HTTP, nie wymaga -`base_url`, a serwer nie musi nawet działać. +| # | Decyzja | Uzasadnienie | +|---|---|---| +| D1 | Narzędzia z `bpp-mcp` (zależność), zero kopii w `src/` | jedna implementacja, zero rozjazdu | +| D2 | Montaż: **własny dyspozytor ASGI** zamiast `ProtocolTypeRouter` | `ProtocolTypeRouter` nie obsługuje `lifespan`, a bez niego menedżer sesji nie wstaje **[probe]** | +| D3 | `stateless_http=True` + `json_response=True` | recykling workera zabiłby sesję stateful; `json_response` znosi SSE, więc `proxy_read_timeout 300s` przestaje być tematem | +| D4 | Dane przez `httpx.ASGITransport` na `django_asgi_app` | ta sama ścieżka kodu co przez sieć, bez gniazda | +| D5 | Wewnętrzne żądanie **dziedziczy `Host`, scheme i IP klienta** z zewnętrznego | inaczej `Uczelnia=None` → obejście bramki i wyciek (§2.5) | +| D6 | Przekazujemy **wyłącznie `Bearer`**, nigdy surowy `Authorization` ani `Cookie` | inaczej Basic i sesja omijają model OAuth (§7.1) | +| D7 | Polityka dostępu dziedziczona z `/api/v1/` | anonim czyta publiczne, bearer odblokowuje resztę | +| D8 | Read-only | narzędzia wykonują wyłącznie GET | + +### 4.1 Co zostało obalone z pierwszej wersji + +Pierwsza wersja proponowała `config.api_root` wskazujący na +`http://bpp.invalid/api/v1` z uzasadnieniem „host jest nieużywany przy +`ASGITransport`". **To było fałszywe na trzech poziomach naraz** — +`ALLOWED_HOSTS`, `SECURE_SSL_REDIRECT` i przede wszystkim rozstrzyganie +`Uczelnia` z hosta (§2.5). Zastąpione przez D5. --- -## 4. Architektura +## 5. Architektura ``` - gunicorn + UvicornWorker - │ - django_bpp.asgi:application - │ - ┌────────────┴────────────┐ - ścieżka /mcp* wszystko inne - │ │ - aplikacja FastMCP django_asgi_app - (stateless streamable HTTP) ▲ - │ │ - narzędzia z bpp_mcp.tools │ - │ │ - BppClient(transport=ASGITransport(──┘) + gunicorn + UvicornWorker + │ + django_bpp.asgi:application = Dyspozytor + │ + ┌────────────────────┼────────────────────┐ + scope=lifespan scope=http scope=websocket + │ │ │ + lifespan aplikacji ┌─────┴─────┐ channels + MCP (nasz!) /mcp* reszta + │ │ + KontekstMcpMiddleware│ + │ │ + aplikacja MCP │ + │ │ + register_tools │ + │ │ + BppClientInProcess ────────┴──→ django_asgi_app + (ASGITransport) ``` -Klucz: `ASGITransport` dostaje **`django_asgi_app`**, nie `application`. -`application` to `ProtocolTypeRouter` zawierający routing na `/mcp` — -podanie go groziłoby rekurencją. +### 5.1 Dyspozytor — rozwiązanie blokera lifespanu -### 4.1 Nowa aplikacja Django: `src/mcp_server/` +`ProtocolTypeRouter` z channels rzuca `ValueError` na scope `lifespan`, więc +menedżer sesji MCP nigdy nie wstaje. Probe potwierdził objaw i potwierdził, że +**wystarczy samemu obsłużyć ten scope**: -Zgodnie z konwencją repo (aplikacja per obszar funkcjonalny): - -``` -src/mcp_server/ - __init__.py - apps.py - asgi_app.py # budowa aplikacji FastMCP + montaż narzędzi - client.py # BppClientInProcess (podklasa z bpp_mcp) - auth.py # rozstrzyganie bearera na wejściu /mcp - views.py # strona /mcp/ dla człowieka (HTML) - urls.py # /mcp/ (HTML) — endpoint protokołu jest w asgi.py - templates/mcp_server/index.html - tests/ +```python +class Dyspozytor: + """Zastępuje ProtocolTypeRouter: trzy scope'y zamiast dwóch.""" + + async def __call__(self, scope, receive, send): + if scope["type"] == "lifespan": + await self._lifespan(scope, receive, send) # nasz, dla MCP + elif scope["type"] == "http" and self._to_mcp(scope["path"]): + await self._mcp(scope, receive, send) + elif scope["type"] == "websocket": + await self._ws(scope, receive, send) # channels + else: + await self._django(scope, receive, send) ``` -Warstwa OAuth **zostaje w `oauth_mcp`** — `mcp_server` z niej korzysta, -nie duplikuje. Nowy widok RFC 9728 trafia do `oauth_mcp/views_metadata.py`, -obok istniejącego RFC 8414 (patrz §6.1). +`_lifespan` na `lifespan.startup` wchodzi w +`self._mcp.router.lifespan_context(self._mcp)`, na `lifespan.shutdown` z niego +wychodzi, a błąd startu raportuje jako `lifespan.startup.failed` — **nie +połyka go**, inaczej wracamy do cichej awarii, tylko innej. ---- +`_to_mcp` dopasowuje **ścieżkę**, nie metodę: SDK obsługuje na trasie +streamable również `GET` (SSE) i `DELETE`. Rozdzielenie „POST do protokołu, GET +do HTML-a" z pierwszej wersji było błędne. -## 5. Dostęp do danych — `BppClientInProcess` +Aplikacja MCP sama routuje na `streamable_http_path` (domyślnie `/mcp`), więc +dyspozytor **nie obcina prefiksu**. -```python -# src/mcp_server/client.py -import httpx -from bpp_mcp.client import BppClient -from django_bpp.asgi import django_asgi_app +### 5.2 Kontekst żądania — `KontekstMcpMiddleware` +Cienka warstwa ASGI przed aplikacją MCP. Zdejmuje z **zewnętrznego** żądania to, +czego wewnętrzne nie ma skąd wziąć, i zapisuje w `ContextVar`: -class BppClientInProcess(BppClient): - """BppClient wołający własną aplikację Django bez gniazda TCP.""" +| Co | Po co | +|---|---| +| `Host` | rozstrzygnięcie `Uczelnia` → bramka API i `ukryte_statusy` (§2.5) | +| scheme | `build_absolute_uri`, brak pętli `SECURE_SSL_REDIRECT` | +| IP klienta | throttling `SearchAnonThrottle` liczy po IP (§7.3) | +| bearer (jeśli jest) | przekazanie do wewnętrznego żądania | +| świeży słownik cache | izolacja `_slownik_cache()` per żądanie | + +Reset na wyjściu obowiązkowy — klient MCP żyje w lifespanie, czyli między +żądaniami, a `ContextVar` bez resetu wycieka do następnego. - def __init__(self, config, **kwargs): +### 5.3 `BppClientInProcess` + +```python +class BppClientInProcess(BppClient): + def __init__(self, config, **kw): super().__init__( config, - transport=httpx.ASGITransport(app=django_asgi_app), - **kwargs, + transport=httpx.ASGITransport( + app=django_asgi_app, # NIE `application` — rekurencja + raise_app_exceptions=False, # 500 → BppNetworkError, nie traceback + ), + tryb_auth=TrybAuth.W_PROCESIE, + max_retries=0, # nie ma sieci do ponowienia + **kw, ) -``` - -Odziedziczona mechanika — co żyje, a co umiera: - -| Element `BppClient` | W procesie | -|---|---| -| retry ×2 + backoff | **martwy** — brak sieci do ponowienia; `max_retries=0` | -| semafor współbieżności (8) | **zostaje** — dławi równoległą pracę bazy | -| cache prefiksów słownikowych | **zostaje** bez zmian | -| auto-follow paginacji | **zostaje** — `next` z DRF jest bezwzględny, `_full_url` to obsługuje | -| mapowanie błędów (404 → `BppNotFound`) | **zostaje** — te same kody statusu | -`config.api_root` wskazuje na `http:///api/v1` — host jest -nieużywany przy `ASGITransport`, ale musi być syntaktycznie poprawny, bo -`httpx` buduje z niego `URL`. Ustalamy `http://bpp.invalid/api/v1`, żeby -przypadkowe wyjście do sieci **nie mogło się udać po cichu**. + def _slownik_cache(self): + return cache_zadania.get() # ContextVar z §5.2 +``` -### 5.1 Zmiana wymagana w `bpp-mcp` (drugie repo) +`config.base_url` składamy **per żądanie** z `Host` i scheme zewnętrznego +żądania. Host `bpp.invalid` z pierwszej wersji jest wycofany (§4.1). -`BppClient.__init__` musi przyjąć opcjonalny `transport` i przekazać go do -`httpx.AsyncClient`. Jedna linia sygnatury + jedna w konstruktorze; domyślne -zachowanie (`transport=None`) bez zmian, więc niekompatybilności nie ma. +Odziedziczona mechanika: -Wymaga wydania `bpp-mcp` przed wydaniem BPP. Kolejność w planie: -PR w `bpp-mcp` → release na PyPI → pin w `pyproject.toml` BPP. +| Element | W procesie | +|---|---| +| retry + backoff | **martwy** (`max_retries=0`) | +| timeouty `httpx` | **martwe** — `ASGITransport` ich nie czyta; sufit musi dać host | +| semafor (8) | **zostaje** — dławi równoległe rozwijanie hyperlinków | +| cache | **przekierowany** na słownik per-żądanie | +| auto-follow paginacji | **zostaje** | +| mapowanie błędów | **zostaje** | --- -## 6. Uwierzytelnianie i polityka dostępu +## 6. Uwierzytelnianie i discovery -### 6.1 Przepływ na wejściu `/mcp` +### 6.1 Przepływ ``` -brak nagłówka Authorization → przepuść bez niego → AnonymousUser - → dane publiczne (polityka /api/v1/) -ważny bearer → przepuść nagłówek dalej - → DRF ustala tożsamość StrictOAuth2Authentication -nieważny / wygasły / bez scope → 401 + WWW-Authenticate +brak Bearera → wewnętrzne żądanie bez Authorization → AnonymousUser +ważny Bearer → przekaż WYŁĄCZNIE ten nagłówek → StrictOAuth2Authentication +nieważny / zły scope → 401 + WWW-Authenticate ``` -Nagłówek `WWW-Authenticate` przy 401: +### 6.2 Discovery -``` -Bearer resource_metadata="https:///.well-known/oauth-protected-resource" -``` - -Nowy widok w `oauth_mcp/views_metadata.py` (RFC 9728), bliźniak istniejącego -RFC 8414 — ten sam wzorzec `build_absolute_uri` dla wielo-domenowości: +Nowy widok w `oauth_mcp/views_metadata.py` — `/.well-known/oauth-protected-resource` +(RFC 9728), bliźniak istniejącego RFC 8414, ten sam wzorzec `build_absolute_uri`: ```json -{ - "resource": "https:///mcp", +{ "resource": "https:///mcp", "authorization_servers": ["https://"], "scopes_supported": ["read"], - "bearer_methods_supported": ["header"] -} + "bearer_methods_supported": ["header"] } ``` -Spec z 2026-07-11 §6 przypisywał ten plik pakietowi `bpp-mcp`. **Przenosi się -do BPP** — Resource Serverem jest teraz instancja. +Nagłówek przy 401: +`Bearer resource_metadata="https:///.well-known/oauth-protected-resource"` -### 6.2 Jedno źródło prawdy o ważności tokenu +Poprzedni spec (§6) przypisywał ten plik pakietowi `bpp-mcp`. **Przenosi się do +BPP** — Resource Serverem jest instancja. -Reguły „co znaczy ważny token MCP" żyją dziś w dwóch miejscach: +### 6.3 Allowlista hostów aplikacji MCP -- `authentication.py` — `is_active` + wymagany scope `read` + twarde 401, -- `middleware.py::_ma_wazny_bearer` — `AccessToken.is_valid()`. +**[probe]** `TransportSecuritySettings` to druga, niezależna od Django lista +dozwolonych hostów; nieznany `Host` → 421. Zasilamy ją z `ALLOWED_HOSTS` +(i `DJANGO_BPP_HOSTNAMES` w instalacji wielouczelnianej). **Nie wyłączamy** — +to ochrona przed DNS-rebinding. -Warstwa MCP byłaby trzecim. **Wyciągamy je do `oauth_mcp/tokens.py`** -i konsumujemy w trzech miejscach. To nie refaktor na boku — bez tego -„ważny token" znaczy trzy różne rzeczy, a rozjazd jest kwestią czasu. +Bez tego kroku wszystkie wdrożenia poza jednym dostaną 421 i nikt nie będzie +wiedział dlaczego. -Sygnatura: +--- -```python -def zweryfikuj_bearer(raw: str) -> tuple[User, AccessToken]: - """Podnosi BearerNiewazny gdy token nie istnieje, wygasł, konto jest - nieaktywne albo brakuje scope 'read'.""" -``` +## 7. Bezpieczeństwo -`StrictOAuth2Authentication` zachowuje integrację z DRF (dziedziczenie po -`OAuth2Authentication`), ale **reguły** po-sprawdzające deleguje do helpera. +### 7.1 Przekazujemy WYŁĄCZNIE `Bearer` -### 6.3 Warstwa MCP dekoduje token wyłącznie dla kodu HTTP +Allowlista nagłówków wewnętrznego żądania: **`Authorization` tylko gdy schemat +to `Bearer`**, plus `Accept`. Odcinamy jawnie `Cookie`, `X-Forwarded-*`, +`Referer` i wszystko inne. -Kuszące jest rozstrzygnąć token raz w warstwie MCP i podać widokowi gotowe -`request.user`. **Nie robimy tego** — powstałby drugi tor uwierzytelniania, -mogący rozjechać się ze `StrictOAuth2Authentication`, klasą o niebanalnych -regułach chronionych testami w `test_authentication.py`. +Dwa niezależne obejścia, gdyby przekazywać hurtem: -Przekazujemy **surowy nagłówek** do wewnętrznego żądania; DRF uwierzytelnia -normalnie, tą samą klasą. Warstwa MCP dekoduje token tylko po to, by -zdecydować między 401 transportu a przepuszczeniem. +- **`Cookie`** → `SessionAuthentication` (`base.py:1058`) uwierzytelnia sesją. + Pełne uprawnienia zalogowanego, bez zgody, bez scope, bez revoke. CSRF nie + chroni, bo wykonujemy GET. +- **`Authorization: Basic`** → `BasicAuthentication` (`base.py:1059`) + uwierzytelnia hasłem. Do tego `ApiReadOnlyForBearerMiddleware` sprawdza + **wyłącznie prefiks `bearer `** (`middleware.py:33`), więc dla Basica druga + warstwa read-only **nie istnieje**. -### 6.4 `whoami` przestaje być potrzebne dla tej ścieżki +Oba wymagają testu regresyjnego. -`bpp-mcp` robi round-trip do `/api/v1/whoami/`, bo token jest opaque, a pakiet -stoi po drugiej stronie sieci. Tutaj weryfikacja to lookup w tej samej bazie. -Znika `WhoamiUnavailable` i rozróżnianie „token nieważny (401)" od „BPP -nieosiągalne (500)". **Endpoint `whoami` zostaje** — używa go nadal `bpp-mcp`. +### 7.2 Propagacja `Host` — to jest kontrola dostępu, nie kosmetyka ---- +Wewnętrzne żądanie **musi** nieść `Host` zewnętrznego. Inaczej `Uczelnia=None`, +a wtedy (§2.5): -## 7. Bezpieczeństwo +- `BramkaApiV1` **przepuszcza bezwarunkowo** — obejście wszystkich sześciu + przełączników, w tym `api_v1_wlaczone` i `api_v1_tylko_zalogowani`. API + wyłączone przez administratora staje się dostępne przez `/mcp`. +- `ukryte_statusy` → `None` → wyciekają rekordy ukrytych statusów korekty. +- W instalacji wielouczelnianej: nie wiadomo, czyje dane zwrócić. -### 7.1 Przekazujemy WYŁĄCZNIE `Authorization` — nigdy `Cookie` +Test wielo-hostowy obowiązkowy. -`SessionAuthentication` jest w `DEFAULT_AUTHENTICATION_CLASSES` -(`base.py:1058`). Gdyby warstwa MCP przekazywała nagłówki hurtem, klient -wysyłający `Cookie:` do `/mcp` **uwierzytelniłby się sesją**, omijając cały -model OAuth — z pełnymi uprawnieniami zalogowanego użytkownika i bez zgody, -bez revoke, bez scope. +### 7.3 Throttling nie może zapaść się do jednego kubełka -Allowlista nagłówków wewnętrznego żądania: `Authorization`, `Accept`. -Wszystko inne (`Cookie`, `X-Forwarded-*`, `Referer`) **odcinamy jawnie**. -Test regresyjny obowiązkowy. +`ASGITransport` ustawia `client=("127.0.0.1", 123)`. `SearchAnonThrottle` liczy +po IP → **wszyscy anonimowi użytkownicy MCP dzieliliby jeden limit**, czyli +jeden klient DoS-uje wszystkich. Dyspozytor wstawia do wewnętrznego scope'u +`client` = IP rozstrzygnięte z zewnętrznego żądania. -### 7.2 Read-only +IP bierzemy przez istniejące `django_bpp.client_ip.get_client_ip` (używa go już +`views_dcr.py`), **nie** przez ślepe przepisanie `X-Forwarded-For`. -`ApiReadOnlyForBearerMiddleware` biegnie w wewnętrznym żądaniu (bo idzie ono -przez pełny stos Django) — czyli mutacja bearerem dostanie 403 tak samo jak -przez sieć. Niezależnie od tego narzędzia MCP wykonują **wyłącznie GET**. -Dwie warstwy, jak w spec §5.4 z 2026-07-11. +Osobno: strefa nginx `bpp_api` (60 r/s) **jest omijana** — wewnętrzne żądania +nie przechodzą przez nginx, a jedno wywołanie `pobierz_rekord` rozwija się na +N żądań API. Kierunek ryzyka jest odwrotny do tego, co pisała pierwsza wersja +specu (obawa o zbyt ciasny limit); realnie limit **nie działa** na tej ścieżce. -### 7.3 Rekurencja +### 7.4 Rekurencja `ASGITransport` dostaje `django_asgi_app`, nie `application`. Dodatkowo -zabezpieczenie: `BppClientInProcess` odrzuca ścieżki spoza `/api/v1/`. +`BppClientInProcess` odrzuca ścieżki spoza `/api/v1/`. -### 7.4 Otwarte DCR na domenie produkcyjnej +### 7.5 Widoczność -Bez zmian względem stanu obecnego — `/o/register/` już jest publiczne, ma -rate-limit i sprzątanie osieroconych rejestracji. `/mcp` nie pogarsza sytuacji, -ale **zwiększa ruch na tej ścieżce**, więc rate-limit trzeba obserwować. +Wewnętrzne żądania nie trafiają do access-logu nginx ani uvicorna — ruch API +generowany przez MCP jest **niewidoczny dla dotychczasowego monitoringu**. +Logujemy je po stronie `KontekstMcpMiddleware` (ścieżka, kod, czas, obecność +bearera — **nigdy sam token**). --- -## 8. Discovery i strona dla człowieka +## 8. Strona `/mcp/` dla człowieka -Pod `/mcp` mieszkają dwie rzeczy o różnych odbiorcach: +`GET /mcp/` (ze slashem) → widok Django z instrukcją podłączenia; `POST /mcp` → +protokół. Rozdzielenie po ścieżce, nie po metodzie (§5.1). -- **`POST /mcp`** — protokół MCP (JSON-RPC over streamable HTTP), obsługiwany - przez aplikację FastMCP w `asgi.py`. -- **`GET /mcp/`** — strona HTML dla człowieka, widok Django w `mcp_server`. +Treść wzorowana na `mcp.sentry.dev`: adres złącza jako główna treść, jedno +zdanie o dostępie, gotowe wklejki dla klientów, link do `bpp-mcp` dla stdio. +Adres składany przez `build_absolute_uri` — każda uczelnia widzi swój. +Szablon podlega `LocaleMiddleware`, więc teksty przez `gettext`. -Rozdzielenie po metodzie i ukośniku jest jednoznaczne: klienci MCP wołają -`POST` na `/mcp`, przeglądarka `GET` na `/mcp/`. +--- + +## 9. Zależności i obraz -Treść strony (wzorzec: `mcp.sentry.dev`): +Dochodzą do `pyproject.toml`: + +``` +bpp-mcp>=0.4,<0.5 # narzędzia + szwy +``` -- adres złącza jako główna treść: `https:///mcp` -- jedno zdanie o dostępie: publiczny odczyt bez logowania; token odblokowuje - dane niepubliczne -- gotowe wklejki per klient: `claude mcp add`, JSON dla Cursora, kroki VS Code -- link do `bpp-mcp` dla trybu stdio +co ciągnie `mcp>=2,<3`, `httpx` i `starlette`. **Żadnego z nich nie ma dziś +w `uv.lock` BPP.** Konsekwencje do sprawdzenia przed scaleniem: -Strona korzysta z `request.build_absolute_uri`, więc każda uczelnia widzi -**swój** adres bez konfiguracji. +- rozmiar obrazu produkcyjnego, +- bramka Trivy w `build-docker-images.yml` (nowe pakiety = nowe CVE), +- czy `starlette` nie koliduje z wersją ciągniętą przez `channels[daphne]`. --- -## 9. Wdrożenie +## 10. Wdrożenie -Nic do zrobienia w `bpp-deploy` — `/mcp` jedzie pod istniejącym -`location /`. Dwie rzeczy do obserwacji, nie do zmiany na wejściu: +Wbrew pierwszej wersji specu, **`bpp-deploy` wymaga zmian**: -1. **`limit_req` z `location /`** obejmie `/mcp`. Agent robiący kilkanaście - wywołań narzędzi pod rząd może się o to obić. Do zmierzenia; jeśli boli — - własna strefa w `_bpp-locations.conf`. -2. **Pula wątków workera.** Zewnętrzne żądanie `/mcp` trzyma worker, a - wewnętrzne wywołania DRF idą do puli wątków tego samego procesu. - Zakleszczenia nie ma (widoki DRF są synchroniczne, więc handler ASGI - odsyła je do wątków), ale rozmiar puli przy równoległym rozwijaniu - hyperlinków w `pobierz_rekord` **wymaga pomiaru**, nie założenia. +1. **ModSecurity/CRS.** Wyłączenie obejmuje dziś tylko + `^/(bpp|api/v1)/zapytanie/`. Ciało JSON-RPC na `/mcp` — zwłaszcza + z zapytaniem DjangoQL w argumentach narzędzia — będzie skanowane przez CRS + i 403 z WAF-a jest bardzo prawdopodobny. Potrzebne wyłączenie dla `/mcp`, + zawężone jak istniejące. +2. **Strefa `limit_req`.** `/mcp` wpada dziś pod `location /` (100 r/s burst + 100). Do zmierzenia; jeśli agent się o to obija — własna strefa. -`stateless_http=True` sprawia, że recykling `--max-requests` jest nieszkodliwy -i że nie ma potrzeby sticky sessions. `proxy_read_timeout 300s` nie jest -problemem, bo nie utrzymujemy długich strumieni. +Bez zmian: `stateless_http` sprawia, że recykling `--max-requests` jest +nieszkodliwy i sticky sessions nie są potrzebne; `json_response` znosi problem +`proxy_read_timeout`. --- -## 10. Testy +## 11. Testy + +Konwencja repo: pytest, bez `unittest.TestCase`, `model_bakery.baker`. -Poziomy, zgodnie z konwencją repo (pytest, bez `unittest.TestCase`, -`model_bakery.baker`): +**Dyspozytor** +- scope `lifespan` → `startup.complete`; błąd startu → `startup.failed` + (nie cisza) +- `POST /mcp` przed lifespanem → kontrolowany błąd, nie `RuntimeError` +- ruch spoza `/mcp` trafia do Django; `websocket` do channels +- `GET`/`DELETE` na `/mcp` trafiają do aplikacji MCP (nie do Django) -**Jednostkowe (`src/mcp_server/tests/`)** -- `BppClientInProcess` zwraca dla `/api/v1/publikacje/` to samo, co klient - sieciowy dla tej samej ścieżki (test na `ASGITransport`, `@pytest.mark.django_db`) -- allowlista nagłówków: `Cookie` przekazany do `/mcp` **nie** uwierzytelnia - (§7.1) — test regresyjny -- ścieżka spoza `/api/v1/` odrzucona (§7.3) -- anonim dostaje dane publiczne; nieważny bearer → 401 + `WWW-Authenticate` -- ważny bearer → tożsamość z `request.user` w wewnętrznym żądaniu +**Bezpieczeństwo (obowiązkowe, każdy osobno)** +- `Cookie` wysłany do `/mcp` **nie** uwierzytelnia (§7.1) +- `Authorization: Basic` **nie** jest przekazywany (§7.1) +- wewnętrzne żądanie niesie `Host` zewnętrznego; **test wielo-hostowy**: + dwie `Uczelnia`, dwa hosty, każdy widzi swoje (§7.2) +- `api_v1_wlaczone=False` → `/mcp` też odmawia (dowód, że bramka działa) +- rekord o ukrytym statusie nie wycieka przez `/mcp` +- dwa różne IP → dwa kubełki throttlingu (§7.3) +- ścieżka spoza `/api/v1/` odrzucona (§7.4) -**`oauth_mcp`** -- `oauth-protected-resource` zwraca poprawne URL-e przy różnych hostach - (wielo-domenowość, jak istniejący `test_metadata.py`) -- `zweryfikuj_bearer` — parytet z dotychczasowym zachowaniem - `StrictOAuth2Authentication` i `_ma_wazny_bearer` (testy istniejące muszą - przejść bez zmian) +**Klient** +- `BppClientInProcess` zwraca to samo, co żądanie sieciowe dla tej ścieżki +- cache izolowany między żądaniami (dwa żądania, różne konteksty) +- wyjątek aplikacji → `BppNetworkError`, nie surowy traceback **Protokół** -- `initialize` → `tools/list` → `tools/call` na żywej aplikacji ASGI - (klient MCP z SDK, bez sieci) +- `initialize` → `tools/list` → `tools/call` na żywej aplikacji, bez sieci +- 11 narzędzi + 1 prompt widoczne **Ręcznie, do odnotowania w planie** -- podłączenie prawdziwego klienta (Claude) i przejście tańca OAuth end-to-end +- podłączenie prawdziwego klienta Claude i pełny taniec OAuth --- -## 11. Poza zakresem +## 12. Kryteria akceptacji -- Narzędzia mutujące (zapis/import przez MCP). -- WebMCP jako API przeglądarki (`navigator.modelContext`) — osobny projekt. +- `POST /mcp` odpowiada na `initialize`, `tools/list`, `tools/call` bez tokenu, + zwracając dane publiczne identyczne z `/api/v1/`. +- Ważny bearer → narzędzie widzi tożsamość użytkownika. +- Nieważny bearer → **401** z `WWW-Authenticate` wskazującym PRM. +- `Cookie` i `Basic` wysłane do `/mcp` **nie** uwierzytelniają. +- Dwie uczelnie na dwóch hostach widzą **swoje** dane; `api_v1_wlaczone=False` + blokuje również `/mcp`. +- `/.well-known/oauth-protected-resource` poprawne przy wielu hostach. +- `GET /mcp/` renderuje stronę z adresem właściwym dla hosta. +- Narzędzia pochodzą z `bpp_mcp` — **żadnej kopii** w `src/`. +- `stateless_http=True`, `json_response=True`, brak długożyjących strumieni. +- Testy zielone; `ruff` czysty; `pre-commit` bez uwag. +- Newsfragment w `src/bpp/newsfragments/`. +- Istniejące testy `oauth_mcp` i `api_v1` przechodzą **bez modyfikacji**. + +--- + +## 13. Poza zakresem + +- Narzędzia mutujące (zapis przez MCP). +- **Rozszerzenie allowlisty DCR** o ChatGPT/Cursor — osobna decyzja + bezpieczeństwa (§1.1). +- Konsolidacja reguł tokenu w `oauth_mcp/tokens.py` — projekt 3 (§3). +- WebMCP jako API przeglądarki (`navigator.modelContext`). - Wygaszenie `bpp-mcp`. -- Zasoby (`resources`) i prompty MCP — tylko `tools` w pierwszej wersji. -- Własna strefa `limit_req` w nginksie (dopiero po pomiarze, §9). -- RFC 8707 / audience binding — dziedziczone odstępstwo z poprzedniego specu. +- Zasoby (`resources`) MCP. Prompt `zloz_zapytanie_djangoql` wchodzi, bo + `register_tools` rejestruje go razem z narzędziami — pierwsza wersja specu + wykluczała prompty i jednocześnie wymagała braku kopii, co było sprzeczne. +- Własna strefa `limit_req` — dopiero po pomiarze (§10). +- RFC 8707 / audience binding — dziedziczone odstępstwo. --- -## 12. Otwarte kwestie do rozstrzygnięcia w planie +## 14. Czym ta wersja różni się od pierwszej -1. **Czy klienci MCP odpalają OAuth przy 401 na kolejnym żądaniu, czy tylko - przy `initialize`?** Jeśli tylko przy `initialize`, endpoint anonimowy nigdy - nie zaproponuje logowania i potrzebny jest drugi adres (np. `/mcp/auth`) dla - pełnego dostępu. **Do weryfikacji empirycznej na żywym kliencie**, nie z - lektury specyfikacji. Częściowo łagodzi to istniejący mechanizm hybrydowej - podpowiedzi logowania w treści odpowiedzi narzędzi - (`BppClient.transport` steruje nim dla DjangoQL). -2. Rozmiar puli wątków i zachowanie pod obciążeniem (§9.2). -3. Czy `limit_req` faktycznie boli (§9.1). -4. Wersja `bpp-mcp` do przypięcia po wydaniu szwu `transport=` (§5.1). -5. Czy `config.transport` w `BppClient` (dziś `"stdio"`/`"http"`) potrzebuje - trzeciej wartości `"in-process"` — steruje hybrydową podpowiedzią logowania, - która w tym trybie powinna brzmieć inaczej niż w stdio. -6. Stały komentarz w `bpp_mcp/client.py` twierdzi, że „BPP nie deklaruje - `max_limit` po stronie DRF" — jest nieaktualny (`BppLimitOffsetPagination` - ma twardy cap). Poprawić przy okazji PR-a w `bpp-mcp`. +| Ustalenie recenzji | Werdykt po weryfikacji | +|---|---| +| Bloker: lifespan menedżera sesji | **potwierdzony** [probe], ale rozwiązywalny dyspozytorem (§5.1) — nie wymaga przeprojektowania | +| Bloker: host `bpp.invalid` | **potwierdzony i groźniejszy, niż zgłoszono** — to obejście kontroli dostępu, nie tylko 400 (§7.2) | +| `Cookie` → `SessionAuthentication` | potwierdzony; **dodatkowo `Basic`**, którego pierwsza wersja nie widziała (§7.1) | +| Cache współdzielony | potwierdzony; rozwiązany szwem z `bpp-mcp` 0.4.0 (§5.3) | +| Throttling w jednym kubełku | potwierdzony; kierunek ryzyka w pierwszej wersji był **odwrotny** (§7.3) | +| ModSecurity | potwierdzony — `bpp-deploy` jednak wymaga zmian (§10) | +| Allowlista DCR | potwierdzona — obietnica zasięgu była nieprawdziwa (§1.1) | +| 7 narzędzi z `tools.py` | **błąd** — 11 narzędzi + prompt, wrappery z `server.py` (§2.2) | +| Zakres | rozbity na trzy projekty (§3) | + +Nowe, spoza recenzji — z probe'ów: `stateless_http`/`json_response` przeniesione +do `streamable_http_app()`, `json_response` znoszące SSE, oraz **własna +allowlista hostów aplikacji MCP** (§6.3), o której nie wiedziała ani pierwsza +wersja specu, ani recenzja. --- -## 13. Kryteria akceptacji +## 15. Otwarte kwestie do rozstrzygnięcia w planie -- `POST /mcp` odpowiada na `initialize`, `tools/list`, `tools/call` bez tokenu, - zwracając dane publiczne identyczne z `/api/v1/`. -- Ważny bearer → wywołanie narzędzia widzi tożsamość użytkownika. -- Nieważny bearer → **401** z `WWW-Authenticate` wskazującym - `oauth-protected-resource`. -- `Cookie` wysłany do `/mcp` **nie** uwierzytelnia (§7.1). -- `/.well-known/oauth-protected-resource` poprawne przy wielu hostach. -- `GET /mcp/` renderuje stronę z adresem złącza właściwym dla hosta. -- Narzędzia pochodzą z `bpp_mcp.tools` — **żadnej kopii** w `src/`. -- `stateless_http=True`; brak długożyjących strumieni. -- Testy zielone; `ruff format` + `ruff check` czyste; `pre-commit` bez uwag. -- Newsfragment w `src/bpp/newsfragments/`. -- Istniejące testy `oauth_mcp` przechodzą **bez modyfikacji**. +1. **Czy klienci MCP odpalają OAuth przy 401 na kolejnym żądaniu, czy tylko + przy `initialize`?** Jeśli tylko przy `initialize`, endpoint anonimowy nigdy + nie zaproponuje logowania i potrzebny jest drugi adres (np. `/mcp/auth`). + **Do weryfikacji empirycznej na żywym kliencie.** Częściowo łagodzi to + istniejąca hybrydowa podpowiedź w treści odpowiedzi narzędzi. +2. Czy `starlette` z `bpp-mcp` koliduje z wersją z `channels[daphne]` (§9). +3. Wpływ nowych zależności na bramkę Trivy (§9). +4. Czy `limit_req` faktycznie boli (§10). +5. Kształt reguły wyłączającej ModSecurity dla `/mcp` (§10). +6. Czy `FirstRunWizardMiddleware` przekierowuje wewnętrzne `/api/v1/` na + świeżej instalacji — niezweryfikowane bez uruchomienia. +7. Czy Rollbar scrubuje `Authorization` dla wyjątków z wewnętrznego scope'u. From 76856bf181fbf727dcffdd6831c559c4db096f81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sun, 6 Sep 2026 12:33:56 +0200 Subject: [PATCH 03/29] =?UTF-8?q?docs(spec):=20lifespan=20przez=20dopisany?= =?UTF-8?q?=20klucz,=20zamiast=20zast=C4=99powania=20routera?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU --- ...6-09-05-mcp-hostowany-w-serwisie-design.md | 117 +++++++++++------- 1 file changed, 70 insertions(+), 47 deletions(-) diff --git a/docs/superpowers/specs/2026-09-05-mcp-hostowany-w-serwisie-design.md b/docs/superpowers/specs/2026-09-05-mcp-hostowany-w-serwisie-design.md index 10a4a8b44..15f155cd1 100644 --- a/docs/superpowers/specs/2026-09-05-mcp-hostowany-w-serwisie-design.md +++ b/docs/superpowers/specs/2026-09-05-mcp-hostowany-w-serwisie-design.md @@ -102,9 +102,12 @@ Wydany dziś (projekt 2, PR-y #21–#23). Wystawia: ### 2.4 Infrastruktura - ASGI: `src/django_bpp/asgi.py` — `ProtocolTypeRouter` z - `{"http": django_asgi_app, "websocket": ...}`. **`ProtocolTypeRouter` rzuca - `ValueError` na scope `lifespan`**, a uvicorn w trybie `auto` loguje to jako - „appears unsupported" i jedzie dalej — czyli błąd jest cichy. + `{"http": django_asgi_app, "websocket": ...}`. Sam router to piętnaście linii + (`channels/routing.py:45-51`): lookup w słowniku, a dla typu spoza słownika + `raise ValueError`. Klucza `"lifespan"` tam po prostu **nie ma**, więc uvicorn + w trybie `auto` dostaje `ValueError`, loguje „lifespan appears unsupported" + i jedzie dalej — błąd jest cichy. To NIE jest ograniczenie channels, tylko + brakujący wpis w naszej konfiguracji (§5.1). - Produkcja: `gunicorn django_bpp.asgi:application` z `UvicornWorker`; `max_requests=1000` + jitter 200 w `docker/appserver/gunicorn_conf.py` (**nie** w entrypoincie). Gałąź dev to `uvicorn --reload` bez gunicorna. @@ -141,7 +144,7 @@ niezależne przedsięwzięcia. Rozbicie: | # | Projekt | Status | |---|---|---| -| 1 | **Hosting MCP w BPP** — dyspozytor, klient w procesie, discovery, strona | **ten spec** | +| 1 | **Hosting MCP w BPP** — routing ASGI, klient w procesie, discovery, strona | **ten spec** | | 2 | Szwy w `bpp-mcp` | **zrobione** — 0.4.0 na PyPI | | 3 | Konsolidacja reguł tokenu w `oauth_mcp/tokens.py` | osobny spec, §13 | @@ -156,7 +159,7 @@ konsumuje istniejące `StrictOAuth2Authentication` bez ruszania go. | # | Decyzja | Uzasadnienie | |---|---|---| | D1 | Narzędzia z `bpp-mcp` (zależność), zero kopii w `src/` | jedna implementacja, zero rozjazdu | -| D2 | Montaż: **własny dyspozytor ASGI** zamiast `ProtocolTypeRouter` | `ProtocolTypeRouter` nie obsługuje `lifespan`, a bez niego menedżer sesji nie wstaje **[probe]** | +| D2 | Montaż: **klucz `"lifespan"` dopisany do istniejącego `ProtocolTypeRouter`** | bez obsługi tego scope'u menedżer sesji MCP nie wstaje **[probe]**; dopisanie klucza zostawia gałąź websocketową nietkniętą (§5.1) | | D3 | `stateless_http=True` + `json_response=True` | recykling workera zabiłby sesję stateful; `json_response` znosi SSE, więc `proxy_read_timeout 300s` przestaje być tematem | | D4 | Dane przez `httpx.ASGITransport` na `django_asgi_app` | ta sama ścieżka kodu co przez sieć, bez gniazda | | D5 | Wewnętrzne żądanie **dziedziczy `Host`, scheme i IP klienta** z zewnętrznego | inaczej `Uczelnia=None` → obejście bramki i wyciek (§2.5) | @@ -179,56 +182,76 @@ Pierwsza wersja proponowała `config.api_root` wskazujący na ``` gunicorn + UvicornWorker │ - django_bpp.asgi:application = Dyspozytor - │ + django_bpp.asgi:application = ProtocolTypeRouter + │ (ten sam co dziś, +1 klucz) ┌────────────────────┼────────────────────┐ - scope=lifespan scope=http scope=websocket + "lifespan" "http" "websocket" │ │ │ - lifespan aplikacji ┌─────┴─────┐ channels - MCP (nasz!) /mcp* reszta - │ │ - KontekstMcpMiddleware│ - │ │ - aplikacja MCP │ - │ │ - register_tools │ - │ │ - BppClientInProcess ────────┴──→ django_asgi_app - (ASGITransport) + LifespanMcp RouterHttp AllowedHostsOriginValidator + (nowy) (nowy) + AuthMiddlewareStack + │ ┌───┴───┐ + URLRouter + │ /mcp* reszta ── BEZ ZMIAN ── + │ │ │ + │ KontekstMcpMiddleware + │ │ │ + └──── trzyma ──→ aplikacja MCP + │ │ + register_tools│ + │ │ + BppClientInProcess ────┴──→ django_asgi_app + (ASGITransport) ``` -### 5.1 Dyspozytor — rozwiązanie blokera lifespanu +### 5.1 Lifespan — dopisany klucz, nie nowy router + +`ProtocolTypeRouter` **nie odrzuca lifespanu** — odrzuca to, czego nie ma +w jego słowniku (`channels/routing.py:45-51`: dict lookup, w przeciwnym razie +`raise ValueError`). A `lifespan` to zwyczajny typ scope'u, tej samej kategorii +co `http` i `websocket`. Dziś klucza po prostu nie ma, więc uvicorn dostaje +`ValueError`, uznaje że aplikacja lifespanu nie wspiera, loguje „lifespan +appears unsupported" i **jedzie dalej** — stąd cisza zamiast błędu, i stąd +`RuntimeError: Task group is not initialized` przy pierwszym `POST /mcp` +**[probe]**. -`ProtocolTypeRouter` z channels rzuca `ValueError` na scope `lifespan`, więc -menedżer sesji MCP nigdy nie wstaje. Probe potwierdził objaw i potwierdził, że -**wystarczy samemu obsłużyć ten scope**: +Dla Django to nigdy nie miało znaczenia (nie potrzebuje inicjalizacji przez +lifespan), więc nikt nie zauważył, że ten kanał jest zatkany. Aplikacja MCP +jest pierwszą rzeczą w BPP, która z niego korzysta. + +Rozwiązanie mieści się w `asgi.py`: ```python -class Dyspozytor: - """Zastępuje ProtocolTypeRouter: trzy scope'y zamiast dwóch.""" - - async def __call__(self, scope, receive, send): - if scope["type"] == "lifespan": - await self._lifespan(scope, receive, send) # nasz, dla MCP - elif scope["type"] == "http" and self._to_mcp(scope["path"]): - await self._mcp(scope, receive, send) - elif scope["type"] == "websocket": - await self._ws(scope, receive, send) # channels - else: - await self._django(scope, receive, send) +application = ProtocolTypeRouter( + { + "http": RouterHttp(mcp_app, django_asgi_app), + "websocket": AllowedHostsOriginValidator( # BEZ ZMIAN + AuthMiddlewareStack(URLRouter(websocket_urlpatterns)) + ), + "lifespan": LifespanMcp(mcp_app), # ← nowy klucz + } +) ``` -`_lifespan` na `lifespan.startup` wchodzi w -`self._mcp.router.lifespan_context(self._mcp)`, na `lifespan.shutdown` z niego -wychodzi, a błąd startu raportuje jako `lifespan.startup.failed` — **nie -połyka go**, inaczej wracamy do cichej awarii, tylko innej. +**Świadomie NIE zastępujemy `ProtocolTypeRouter` własnym dyspozytorem** (tak +proponowała poprzednia wersja tej sekcji). Przepisanie routera brałoby na nas +odtworzenie gałęzi websocketowej wraz 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. Przy +dopisaniu klucza ta gałąź zostaje nietknięta bit w bit. + +Patchowanie `channels` odrzucone: jego zachowanie nie jest błędne — dostaje +słownik i robi z nim dokładnie to, co obiecuje. Monkeypatch byłby niewidoczny +w `asgi.py` i pękłby przy aktualizacji. -`_to_mcp` dopasowuje **ścieżkę**, nie metodę: SDK obsługuje na trasie -streamable również `GET` (SSE) i `DELETE`. Rozdzielenie „POST do protokołu, GET -do HTML-a" z pierwszej wersji było błędne. +**`LifespanMcp`** obsługuje protokół lifespan: na `lifespan.startup` wchodzi +w `mcp_app.router.lifespan_context(mcp_app)`, na `lifespan.shutdown` z niego +wychodzi. Błąd startu raportuje jako `lifespan.startup.failed` z komunikatem — +**nie połyka go**, inaczej wracamy do cichej awarii, tylko innej. -Aplikacja MCP sama routuje na `streamable_http_path` (domyślnie `/mcp`), więc -dyspozytor **nie obcina prefiksu**. +**`RouterHttp`** rozdziela po **ścieżce**, nie po metodzie: SDK obsługuje na +trasie streamable również `GET` (SSE) i `DELETE`. Rozdzielenie „POST do +protokołu, GET do HTML-a" z pierwszej wersji specu było błędne. Aplikacja MCP +sama routuje na `streamable_http_path` (domyślnie `/mcp`), więc **nie obcinamy +prefiksu**. ### 5.2 Kontekst żądania — `KontekstMcpMiddleware` @@ -359,7 +382,7 @@ Test wielo-hostowy obowiązkowy. `ASGITransport` ustawia `client=("127.0.0.1", 123)`. `SearchAnonThrottle` liczy po IP → **wszyscy anonimowi użytkownicy MCP dzieliliby jeden limit**, czyli -jeden klient DoS-uje wszystkich. Dyspozytor wstawia do wewnętrznego scope'u +jeden klient DoS-uje wszystkich. `KontekstMcpMiddleware` wstawia do wewnętrznego scope'u `client` = IP rozstrzygnięte z zewnętrznego żądania. IP bierzemy przez istniejące `django_bpp.client_ip.get_client_ip` (używa go już @@ -435,7 +458,7 @@ nieszkodliwy i sticky sessions nie są potrzebne; `json_response` znosi problem Konwencja repo: pytest, bez `unittest.TestCase`, `model_bakery.baker`. -**Dyspozytor** +**Routing ASGI** - scope `lifespan` → `startup.complete`; błąd startu → `startup.failed` (nie cisza) - `POST /mcp` przed lifespanem → kontrolowany błąd, nie `RuntimeError` @@ -505,7 +528,7 @@ Konwencja repo: pytest, bez `unittest.TestCase`, `model_bakery.baker`. | Ustalenie recenzji | Werdykt po weryfikacji | |---|---| -| Bloker: lifespan menedżera sesji | **potwierdzony** [probe], ale rozwiązywalny dyspozytorem (§5.1) — nie wymaga przeprojektowania | +| Bloker: lifespan menedżera sesji | **potwierdzony** [probe], ale rozwiązywalny dopisaniem jednego klucza do `ProtocolTypeRouter` (§5.1) — nie wymaga przeprojektowania | | Bloker: host `bpp.invalid` | **potwierdzony i groźniejszy, niż zgłoszono** — to obejście kontroli dostępu, nie tylko 400 (§7.2) | | `Cookie` → `SessionAuthentication` | potwierdzony; **dodatkowo `Basic`**, którego pierwsza wersja nie widziała (§7.1) | | Cache współdzielony | potwierdzony; rozwiązany szwem z `bpp-mcp` 0.4.0 (§5.3) | From 1a04054f1f79f9cc8cabfcb667dea91663caafd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sun, 6 Sep 2026 13:19:10 +0200 Subject: [PATCH 04/29] =?UTF-8?q?docs(spec):=20wersja=203=20=E2=80=94=20za?= =?UTF-8?q?mkni=C4=99te=20trzy=20blokery=20z=20drugiej=20recenzji?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU --- ...6-09-05-mcp-hostowany-w-serwisie-design.md | 845 +++++++++++------- 1 file changed, 504 insertions(+), 341 deletions(-) diff --git a/docs/superpowers/specs/2026-09-05-mcp-hostowany-w-serwisie-design.md b/docs/superpowers/specs/2026-09-05-mcp-hostowany-w-serwisie-design.md index 15f155cd1..9ae7b3cb2 100644 --- a/docs/superpowers/specs/2026-09-05-mcp-hostowany-w-serwisie-design.md +++ b/docs/superpowers/specs/2026-09-05-mcp-hostowany-w-serwisie-design.md @@ -1,21 +1,21 @@ # Hostowany serwer MCP w serwisie BPP (`/mcp`) — projekt 1 z 3 -Data: 2026-09-05 (przepisany 2026-09-06) +Data: 2026-09-05 (wersja 3, 2026-09-06) Status: projekt do zatwierdzenia Poprzednik: `2026-07-11-mcp-oauth-authorization-design.md` (warstwa OAuth po stronie BPP — **zrealizowana**, `src/oauth_mcp/`) -> **Ten dokument został przepisany.** Pierwsza wersja (commit poprzedzający) -> dostała od recenzji werdykt „wymaga przeprojektowania". Dwa blokery -> potwierdzono, jeden zdegradowano po weryfikacji empirycznej, cztery dziury -> bezpieczeństwa dopisano. Wszystkie zmiany są opisane w §14. +> **Historia dokumentu.** Wersja 1 dostała werdykt „wymaga przeprojektowania". +> Wersja 2 (przepisana po pierwszej recenzji i dwóch probe'ach) dostała +> „niewdrażalne — trzy blokery". Ta wersja zamyka B1–B3 i dwanaście poprawek. +> Rozliczenie w §14. --- ## 1. Cel i motywacja -Dostęp AI do danych BPP idzie dziś przez `bpp-mcp` — pakiet z PyPI, który -użytkownik instaluje do swojego klienta MCP jako serwer stdio. Trzy koszty: +Dostęp AI do danych BPP idzie dziś przez `bpp-mcp` — pakiet z PyPI instalowany +do klienta MCP jako serwer stdio. Trzy koszty: 1. **Próg wejścia.** Potrzebny Python i `uvx`, a przede wszystkim użytkownik musi **znać i wpisać adres swojej instancji** (`BPP_BASE_URL` celowo bez @@ -24,133 +24,148 @@ użytkownik instaluje do swojego klienta MCP jako serwer stdio. Trzy koszty: mobilne **nie mają jak uruchomić procesu lokalnego**. 3. **Skos wersji.** N wersji pakietu × M wdrożeń BPP. -**Cel:** BPP wystawia własny endpoint MCP pod `/mcp`, wyjeżdżający z tym samym -obrazem dockerowym co reszta serwisu. Użytkownik wkleja adres swojej instancji +**Cel:** 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. ### 1.1 Zasięg — uczciwie -Pierwsza wersja tego specu obiecywała „claude.ai, ChatGPT, Cursor, VS Code". -**To była nieprawda.** Allowlista redirect_uri w DCR -(`src/oauth_mcp/views_dcr.py:17-23`) dopuszcza wyłącznie: +Wersja 1 obiecywała „claude.ai, ChatGPT, Cursor, VS Code". **To była +nieprawda.** Allowlista redirect_uri w DCR (`src/oauth_mcp/views_dcr.py:16-22`) +dopuszcza wyłącznie `claude.ai`, `*.claude.ai`, `claude.com`, `localhost` +i `127.0.0.1`. ChatGPT i Cursor **nie zarejestrują klienta**. -``` -https://claude.ai/* https://*.claude.ai/* https://claude.com/* -http://localhost[:port]/* http://127.0.0.1[:port]/* -``` - -ChatGPT i Cursor **nie zarejestrują klienta** — dostaną odmowę na DCR. -Realny zasięg tej pracy to **ekosystem Claude** (web, desktop, Code) plus -klienci lokalne. Rozszerzenie allowlisty jest osobną decyzją bezpieczeństwa, -poza zakresem (§13). - -Dostęp anonimowy nie wymaga DCR w ogóle, więc **publiczna część działa -z każdym klientem** obsługującym zdalne MCP. Ograniczenie dotyczy tylko -logowania. +Realny zasięg **logowania** to ekosystem Claude. **Dostęp anonimowy nie wymaga +DCR w ogóle**, więc publiczna część działa z każdym klientem obsługującym +zdalne MCP. Rozszerzenie allowlisty jest osobną decyzją bezpieczeństwa (§13). --- ## 2. Kontekst — ustalenia z kodu i z probe'ów -Numery linii wg `dev` @ `31d3d4612`. Ustalenia oznaczone **[probe]** pochodzą -z uruchomionego kodu, nie z lektury. +Numery linii wg `dev` @ `31d3d4612`. **[probe]** = ustalenie z uruchomionego +kodu, nie z lektury. ### 2.1 Warstwa OAuth po stronie BPP — istnieje i jest kompletna | Element | Plik | |---|---| | `/o/authorize/`, `/o/token/`, `/o/revoke_token/` | `oauth_mcp/urls.py` | -| DCR `/o/register/` (RFC 7591) + allowlista + rate-limit | `oauth_mcp/views_dcr.py` | -| AS metadata `/.well-known/oauth-authorization-server` (RFC 8414) | `oauth_mcp/views_metadata.py` | +| DCR `/o/register/` (RFC 7591) + allowlista + rate-limit | `oauth_mcp/views_dcr.py:16-22` | +| AS metadata RFC 8414 | `oauth_mcp/views_metadata.py` | | `StrictOAuth2Authentication` — bearer → `request.user`, twarde 401 | `oauth_mcp/authentication.py` | -| `ApiReadOnlyForBearerMiddleware` — mutacje `/api/v1/` bearerem → 403 | `oauth_mcp/middleware.py` | -| `WhoAmIView` — **routing w `api_v1/urls.py:265`**, pod `z_bramka_api_v1` | `oauth_mcp/views_whoami.py` | +| `ApiReadOnlyForBearerMiddleware` — mutacje bearerem → 403 | `oauth_mcp/middleware.py:33` | +| `WhoAmIView` — routing w `api_v1/urls.py:265`, pod `z_bramka_api_v1` | `oauth_mcp/views_whoami.py` | | revoke przy zmianie hasła / dezaktywacji | `oauth_mcp/signals.py` | -`StrictOAuth2Authentication` jest **pierwsza** w -`REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"]` (`base.py:1057`), przed -`SessionAuthentication` (1058) i `BasicAuthentication` (1059). +Kolejność w `DEFAULT_AUTHENTICATION_CLASSES` (`base.py:1057-1059`): +`StrictOAuth2Authentication`, `SessionAuthentication`, `BasicAuthentication`. -### 2.2 Pakiet `bpp-mcp` 0.4.0 — szwy gotowe +### 2.2 Pakiet `bpp-mcp` 0.4.0 -Wydany dziś (projekt 2, PR-y #21–#23). Wystawia: +Wydany (projekt 2, PR-y #21–#23). Wystawia `register_tools(mcp)` (**11 narzędzi ++ 1 prompt**, `server.py:315-330`), `KontekstApp`, `BppClient(transport=...)`, +`TrybAuth.W_PROCESIE`, `BppClient._slownik_cache()`. -- `register_tools(mcp)` — **11 narzędzi + 1 prompt** na instancji `MCPServer` - hosta. (Pierwsza wersja specu mówiła o siedmiu i twierdziła, że rejestrowane - są funkcje z `tools.py`; w rzeczywistości to wrappery z `server.py`, biorące - klienta z `ctx.request_context.lifespan_context`.) -- `KontekstApp` — kontrakt lifespanu (`client`, `bearer_provider`). -- `BppClient(transport=...)` — wstrzykiwany transport `httpx`. -- `TrybAuth.W_PROCESIE` — bearer albo anonim, **nigdy Basic**. -- `BppClient._slownik_cache()` — szew pod cache o zasięgu per-żądanie. +**Ograniczenie, które kształtuje ten projekt:** `BppClient.__init__` zapamiętuje +`self._api_root = config.api_root` **raz** (`client.py:135`), `Config` jest +`frozen` (`config.py:25`), a `_full_url` czyta zapamiętaną wartość +(`client.py:186`). Nie ma szwu na host per żądanie. Stąd D9 (§5.3). -### 2.3 MCP SDK 2.0 — ustalenia z probe'ów +Kontrakt `register_tools` mówi „`KontekstApp` **albo obiekt o tych samych +atrybutach**" (`server.py:291-315`) — i to jest furtka, z której korzystamy. -- **[probe]** `stateless_http` i `json_response` **nie są parametrami - konstruktora** `MCPServer` — mieszkają w `streamable_http_app()`. -- **[probe]** `POST /mcp` **bez wejścia w lifespan** kończy się - `RuntimeError: Task group is not initialized. Make sure to use run().` - Dotyczy również `stateless_http=True`. -- **[probe]** Po wejściu w lifespan aplikacji ta sama ścieżka oddaje **200** - z poprawną odpowiedzią `initialize`. +### 2.3 MCP SDK 2.0 + +- **[probe]** `stateless_http` i `json_response` są parametrami + `streamable_http_app()` (`mcpserver/server.py:1218-1229`), **nie** + konstruktora. +- **[probe]** `POST /mcp` bez wejścia w lifespan → + `RuntimeError: Task group is not initialized` (`streamable_http_manager.py:174`), + także przy `stateless_http=True`. Po wejściu — **200** i poprawny `initialize`. - **[probe]** Aplikacja MCP ma **własną allowlistę hostów** - (`TransportSecuritySettings`), niezależną od `ALLOWED_HOSTS` Django. Nieznany - `Host` → **421 Invalid Host header**. -- W SDK 2.0 **lifespan wchodzi RAZ na serwer**, nie per sesja — jego wynik - dzielą wszystkie sesje i żądania (docstring `bpp_mcp/server.py`). - -### 2.4 Infrastruktura - -- ASGI: `src/django_bpp/asgi.py` — `ProtocolTypeRouter` z - `{"http": django_asgi_app, "websocket": ...}`. Sam router to piętnaście linii - (`channels/routing.py:45-51`): lookup w słowniku, a dla typu spoza słownika - `raise ValueError`. Klucza `"lifespan"` tam po prostu **nie ma**, więc uvicorn - w trybie `auto` dostaje `ValueError`, loguje „lifespan appears unsupported" - i jedzie dalej — błąd jest cichy. To NIE jest ograniczenie channels, tylko - brakujący wpis w naszej konfiguracji (§5.1). -- Produkcja: `gunicorn django_bpp.asgi:application` z `UvicornWorker`; - `max_requests=1000` + jitter 200 w `docker/appserver/gunicorn_conf.py` - (**nie** w entrypoincie). Gałąź dev to `uvicorn --reload` bez gunicorna. -- nginx (`bpp-deploy`): trzy strefy `limit_req` (`_bpp-locations.conf:74-89`) — - `/api/` 60 r/s burst 60, `/admin/` 50 r/s, `/` 100 r/s burst 100. -- **ModSecurity/CRS** przed appserverem; jedyne wyłączenie dla DjangoQL to + (`TransportSecuritySettings`), niezależną od `ALLOWED_HOSTS`. Nieznany + `Host` → **421**. +- Semantyka tej allowlisty **różni się od Django**: dopasowanie dokładne albo + wzorzec `host:*` (`transport_security.py:49-60`). Django-owe `.domena` i `*` + **nie działają**. Sprawdzany jest też `Origin` (`:65-70`). +- Przekazanie `host=` **bez** `transport_security` po cichu **wyłącza** ochronę + (`lowlevel/server.py:738-744`). +- `RequireAuthMiddleware` owija trasę **wyłącznie** gdy podano `token_verifier` + (`lowlevel/server.py:793-806`) — i wtedy odrzuca 401-ką **każde** żądanie bez + tożsamości (`bearer_auth.py:93-97`). Czyli SDK nie zna trybu „anonim + dozwolony, zły token odrzucony". Stąd D10 (§5.4). +- `stateless_http=True` + `GET` → **405** (`streamable_http.py:788-790,848`), + nie SSE. +- Lifespan wchodzi **raz na serwer**; `session_manager.run()` można wejść + **raz na instancję** (`streamable_http_manager.py:137-143`). + +### 2.4 Serwery ASGI — trzy różne, nie jeden + +To jest źródło blokera B2. + +| Środowisko | Serwer | Lifespan | +|---|---|---| +| produkcja | `gunicorn` + `UvicornWorker` (`entrypoint-appserver.sh:104`) | **tak** | +| kontener dev | `uvicorn --reload` (`:94`) | **tak** | +| **lokalnie: `run-site run` → `manage.py runserver`** | **Daphne** (`daphne` w `INSTALLED_APPS`, `base.py:391`) | **NIE** | +| **testy Playwright: `channels_live_server`** | **Daphne** (`src/channels_live_server.py:11,22`) | **NIE** | + +W źródłach `daphne` **nie ma ani jednego wystąpienia słowa `lifespan`**. + +`ProtocolTypeRouter` to piętnaście linii (`channels/routing.py:45-52`): lookup +w słowniku, a dla typu spoza słownika `raise ValueError` (linie 50-52). Klucza +`"lifespan"` po prostu nie ma, więc uvicorn dostaje `ValueError`, loguje +„lifespan appears unsupported" (`lifespan/on.py:88-93`) i jedzie dalej — błąd +jest cichy. **To nie jest ograniczenie channels, tylko brakujący wpis +w naszej konfiguracji.** + +Pozostała infrastruktura: + +- `max_requests=1000` + jitter 200 w `docker/appserver/gunicorn_conf.py:44-45`; + gunicornowy `timeout` to heartbeat, **nie** limit czasu żądania (`:47-51`). +- nginx: `Host $host`, `X-Forwarded-For`, `X-Forwarded-Proto` + (`_bpp-locations.conf:129-134`), `proxy_read_timeout 300s` (`:140`), trzy + strefy `limit_req` (`:74-89`) — `/api/` 60 r/s, `/admin/` 50 r/s, `/` 100 r/s. +- **ModSecurity/CRS**; jedyne wyłączenie dla DjangoQL to `^/(bpp|api/v1)/zapytanie/` (`modsecurity-override.conf.template:79`). -- `api_v1.pagination.BppLimitOffsetPagination` — `MAKS_LIMIT = 500`, **cichy - clamp pojedynczej strony** (nie odmowa). +- nginx→uvicorn jest **plaintext**; HTTPS Django rozpoznaje z + `X-Forwarded-Proto` (`production.py:161`), a `SECURE_SSL_REDIRECT=True` + (`:168`). ### 2.5 Bramka API i model tożsamości hosta -To jest źródło najgroźniejszego problemu w §7.2. Kolejność w -`BramkaApiV1` (`src/api_v1/permissions.py`), cytat z docstringu: +Kolejność w `BramkaApiV1` (`src/api_v1/permissions.py:66-68`), cytat +z docstringu: > 1. uczelnia nierozstrzygnięta (pusta baza, kreator konfiguracji, brak > mapowania Site→Uczelnia) → **przepuszczamy**, bo nie ma kto podjąć decyzji `Uczelnia` rozstrzyga się z nagłówka `Host` (`SiteResolutionMiddleware`, -`Uczelnia.objects.get_for_request`). Czyli: **request z nieznanym hostem omija -wszystkie sześć przełączników API.** Do tego -`UkryjStatusyKorektyMixin.ukryte_statusy` daje wtedy `None` → rekordy ukrytych -statusów przestają być filtrowane. +`Uczelnia.objects.get_for_request`). Czyli **request z nieznanym hostem omija +wszystkie sześć przełączników API**, a `UkryjStatusyKorektyMixin.ukryte_statusy` +daje `None` (`viewsets/common.py:9-13`) → rekordy ukrytych statusów przestają +być filtrowane. -`SearchAnonThrottle` (`api_v1/throttling.py`) dziedziczy po `AnonRateThrottle`, -czyli **liczy po IP**. +`SearchAnonThrottle` dziedziczy po `AnonRateThrottle`. Bez `NUM_PROXIES` +(nie ma go w settings) DRF kluczuje po **całym** `X-Forwarded-For`, gdy jest +obecny (`rest_framework/throttling.py:29-39`); bez XFF — po `REMOTE_ADDR`, +czyli po `scope["client"]`. + +`get_client_ip` bierze **skrajnie prawy** XFF (`django_bpp/client_ip.py:33-36`). --- ## 3. Trzy projekty, nie jeden -Recenzja pierwszej wersji słusznie wskazała, że dokument mieszał trzy -niezależne przedsięwzięcia. Rozbicie: - | # | Projekt | Status | |---|---|---| -| 1 | **Hosting MCP w BPP** — routing ASGI, klient w procesie, discovery, strona | **ten spec** | +| 1 | **Hosting MCP w BPP** — routing ASGI, klient w procesie, auth, strona | **ten spec** | | 2 | Szwy w `bpp-mcp` | **zrobione** — 0.4.0 na PyPI | | 3 | Konsolidacja reguł tokenu w `oauth_mcp/tokens.py` | osobny spec, §13 | -Projekt 3 wypada z zakresu świadomie: to zmiana w **testowanej warstwie -uwierzytelniania**, i wrzucanie jej do środka feature'a było błędem. Ten spec -konsumuje istniejące `StrictOAuth2Authentication` bez ruszania go. +Projekt 3 wypada świadomie: to zmiana w **testowanej warstwie +uwierzytelniania**, a wrzucanie jej do środka feature'a było błędem. --- @@ -158,29 +173,25 @@ konsumuje istniejące `StrictOAuth2Authentication` bez ruszania go. | # | Decyzja | Uzasadnienie | |---|---|---| -| D1 | Narzędzia z `bpp-mcp` (zależność), zero kopii w `src/` | jedna implementacja, zero rozjazdu | -| D2 | Montaż: **klucz `"lifespan"` dopisany do istniejącego `ProtocolTypeRouter`** | bez obsługi tego scope'u menedżer sesji MCP nie wstaje **[probe]**; dopisanie klucza zostawia gałąź websocketową nietkniętą (§5.1) | -| D3 | `stateless_http=True` + `json_response=True` | recykling workera zabiłby sesję stateful; `json_response` znosi SSE, więc `proxy_read_timeout 300s` przestaje być tematem | -| D4 | Dane przez `httpx.ASGITransport` na `django_asgi_app` | ta sama ścieżka kodu co przez sieć, bez gniazda | -| D5 | Wewnętrzne żądanie **dziedziczy `Host`, scheme i IP klienta** z zewnętrznego | inaczej `Uczelnia=None` → obejście bramki i wyciek (§2.5) | -| D6 | Przekazujemy **wyłącznie `Bearer`**, nigdy surowy `Authorization` ani `Cookie` | inaczej Basic i sesja omijają model OAuth (§7.1) | +| D1 | Narzędzia z `bpp-mcp`, zero kopii w `src/` | jedna implementacja | +| D2 | Klucz `"lifespan"` w istniejącym `ProtocolTypeRouter` **+ leniwy start** | klucz naprawia uvicorna; leniwy start naprawia Daphne (§2.4, §5.1) | +| D3 | `stateless_http=True` + `json_response=True` | recykling workera zabiłby sesję stateful; `json_response` znosi SSE | +| D4 | Dane przez `httpx.ASGITransport` na `django_asgi_app` | ta sama ścieżka kodu, bez gniazda | +| D5 | Wewnętrzne żądanie dziedziczy `Host`, scheme i IP klienta | inaczej `Uczelnia=None` → obejście bramki i wyciek (§7.2) | +| D6 | Przekazujemy **wyłącznie `Bearer`** | inaczej Basic i sesja omijają model OAuth (§7.1) | | D7 | Polityka dostępu dziedziczona z `/api/v1/` | anonim czyta publiczne, bearer odblokowuje resztę | | D8 | Read-only | narzędzia wykonują wyłącznie GET | - -### 4.1 Co zostało obalone z pierwszej wersji - -Pierwsza wersja proponowała `config.api_root` wskazujący na -`http://bpp.invalid/api/v1` z uzasadnieniem „host jest nieużywany przy -`ASGITransport`". **To było fałszywe na trzech poziomach naraz** — -`ALLOWED_HOSTS`, `SECURE_SSL_REDIRECT` i przede wszystkim rozstrzyganie -`Uczelnia` z hosta (§2.5). Zastąpione przez D5. +| **D9** | **`BppClient` per żądanie**, przez property w obiekcie lifespanu | `_api_root` zapamiętany w `__init__` (§2.2); rozwiązuje też cache i semafor | +| **D10** | **Dwa adresy: `/mcp` (publiczny) i `/mcp/auth` (zawsze 401 bez tokenu)** | SDK nie zna trybu „anonim tak, zły token nie" (§2.3); dwa adresy usuwają zależność od nieznanego zachowania klientów | +| **D11** | **Jawny sufit czasu i współbieżności** w naszej warstwie | timeouty `httpx` martwe przy `ASGITransport`, gunicornowy `timeout` to heartbeat (§2.4) | +| **D12** | `follow_redirects=False` w kliencie w procesie | inaczej `SECURE_SSL_REDIRECT` po cichu podwaja każde żądanie, a kreator instalacji przekierowuje na HTML (§7.6) | --- ## 5. Architektura ``` - gunicorn + UvicornWorker + gunicorn+uvicorn (prod) | Daphne (dev, testy) │ django_bpp.asgi:application = ProtocolTypeRouter │ (ten sam co dziś, +1 klucz) @@ -189,35 +200,30 @@ Pierwsza wersja proponowała `config.api_root` wskazujący na │ │ │ LifespanMcp RouterHttp AllowedHostsOriginValidator (nowy) (nowy) + AuthMiddlewareStack - │ ┌───┴───┐ + URLRouter - │ /mcp* reszta ── BEZ ZMIAN ── - │ │ │ - │ KontekstMcpMiddleware - │ │ │ - └──── trzyma ──→ aplikacja MCP - │ │ - register_tools│ - │ │ - BppClientInProcess ────┴──→ django_asgi_app - (ASGITransport) + │ ┌─────┴─────┐ + URLRouter + │ /mcp* reszta ── BEZ ZMIAN ── + │ │ │ + │ BramkaBearera │ ← 401 + WWW-Authenticate (§5.4) + │ │ │ + │ KontekstMcp │ ← host/scheme/IP/limity → ContextVar + │ │ │ + └── start ──→ aplikacja MCP│ + │ │ + register_tools │ + │ │ + BppClient (per żądanie, D9) + │ │ + ASGITransport │ + │ │ + KlientScope ─────────┤ ← nadpisuje scope["client"] (§5.2) + │ │ + └──→ django_asgi_app ←┘ ``` -### 5.1 Lifespan — dopisany klucz, nie nowy router - -`ProtocolTypeRouter` **nie odrzuca lifespanu** — odrzuca to, czego nie ma -w jego słowniku (`channels/routing.py:45-51`: dict lookup, w przeciwnym razie -`raise ValueError`). A `lifespan` to zwyczajny typ scope'u, tej samej kategorii -co `http` i `websocket`. Dziś klucza po prostu nie ma, więc uvicorn dostaje -`ValueError`, uznaje że aplikacja lifespanu nie wspiera, loguje „lifespan -appears unsupported" i **jedzie dalej** — stąd cisza zamiast błędu, i stąd -`RuntimeError: Task group is not initialized` przy pierwszym `POST /mcp` -**[probe]**. - -Dla Django to nigdy nie miało znaczenia (nie potrzebuje inicjalizacji przez -lifespan), więc nikt nie zauważył, że ten kanał jest zatkany. Aplikacja MCP -jest pierwszą rzeczą w BPP, która z niego korzysta. +### 5.1 Lifespan — dopisany klucz plus leniwy start -Rozwiązanie mieści się w `asgi.py`: +**Klucz.** `ProtocolTypeRouter` nie odrzuca lifespanu; odrzuca to, czego nie ma +w słowniku (§2.4). Dopisujemy: ```python application = ProtocolTypeRouter( @@ -231,94 +237,172 @@ application = ProtocolTypeRouter( ) ``` -**Świadomie NIE zastępujemy `ProtocolTypeRouter` własnym dyspozytorem** (tak -proponowała poprzednia wersja tej sekcji). Przepisanie routera brałoby na nas -odtworzenie gałęzi websocketowej wraz 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. Przy -dopisaniu klucza ta gałąź zostaje nietknięta bit w bit. +**Świadomie NIE zastępujemy routera własnym dyspozytorem** — przepisanie +brałoby na nas odtworzenie gałęzi websocketowej wraz z +`AllowedHostsOriginValidator` i `AuthMiddlewareStack`, czyli ruszanie działającej +warstwy bezpieczeństwa po to, żeby naprawić rzecz, która jej nie dotyczy. +Patchowanie `channels` odrzucone: jego zachowanie nie jest błędne, a monkeypatch +byłby niewidoczny w `asgi.py` i pękłby przy aktualizacji. -Patchowanie `channels` odrzucone: jego zachowanie nie jest błędne — dostaje -słownik i robi z nim dokładnie to, co obiecuje. Monkeypatch byłby niewidoczny -w `asgi.py` i pękłby przy aktualizacji. +**Leniwy start.** Sam klucz naprawia uvicorna i **nie naprawia Daphne**, pod +którym chodzi `run-site run` i `channels_live_server` (§2.4). Dlatego start +menedżera sesji jest **idempotentny i wyzwalany z obu stron**: + +```python +class StartMcp: + """Wejście w session_manager.run() dokładnie raz, skądkolwiek przyjdzie.""" + + def __init__(self, mcp_app): + self._app = mcp_app + self._lock = anyio.Lock() + self._stack: AsyncExitStack | None = None + + async def zapewnij(self) -> None: + if self._stack is not None: + return + async with self._lock: + if self._stack is not None: # double-check pod blokadą + return + stack = AsyncExitStack() + await stack.enter_async_context( + self._app.router.lifespan_context(self._app) + ) + self._stack = stack +``` -**`LifespanMcp`** obsługuje protokół lifespan: na `lifespan.startup` wchodzi -w `mcp_app.router.lifespan_context(mcp_app)`, na `lifespan.shutdown` z niego -wychodzi. Błąd startu raportuje jako `lifespan.startup.failed` z komunikatem — -**nie połyka go**, inaczej wracamy do cichej awarii, tylko innej. +`LifespanMcp` woła `zapewnij()` na `lifespan.startup`; `RouterHttp` woła je +przed pierwszym żądaniem do `/mcp*`. Pod uvicornem zadziała pierwsze, pod +Daphne — drugie. **`session_manager.run()` można wejść raz na instancję** +(`streamable_http_manager.py:137-143`), więc blokada i double-check są +obowiązkowe, nie ozdobne. -**`RouterHttp`** rozdziela po **ścieżce**, nie po metodzie: SDK obsługuje na -trasie streamable również `GET` (SSE) i `DELETE`. Rozdzielenie „POST do -protokołu, GET do HTML-a" z pierwszej wersji specu było błędne. Aplikacja MCP -sama routuje na `streamable_http_path` (domyślnie `/mcp`), więc **nie obcinamy -prefiksu**. +Kontekst zadania: `run()` zakłada grupę zadań anyio, która żyje tak długo, jak +kontekst, w którym ją utworzono. Przy starcie z żądania trzymamy `AsyncExitStack` +w obiekcie modułowym, więc nie ginie z żądaniem — **to jest miejsce wymagające +własnego testu**, nie założenia (§11). -### 5.2 Kontekst żądania — `KontekstMcpMiddleware` +**Błąd startu** raportujemy jako `lifespan.startup.failed` z komunikatem. Pod +gunicornem oznacza to `should_exit` w uvicornie (`lifespan/on.py:58-60`) → +worker pada → gunicorn respawnuje. Czyli **boot-loop, nie cisza** — i tak ma +być: awaria startu MCP nie może przejść niezauważona. -Cienka warstwa ASGI przed aplikacją MCP. Zdejmuje z **zewnętrznego** żądania to, -czego wewnętrzne nie ma skąd wziąć, i zapisuje w `ContextVar`: +**`RouterHttp`** rozdziela po **ścieżce**, nie po metodzie. `GET /mcp` +z `Accept: text/html` (człowiek wkleił adres do przeglądarki) przekierowujemy +na `/mcp/` — inaczej SDK odda 406 „Client must accept text/event-stream" +(`streamable_http.py:698-703`). Aplikacja MCP sama routuje na +`streamable_http_path`, więc **nie obcinamy prefiksu**. + +### 5.2 `KontekstMcp` i `KlientScope` — dwie warstwy, nie jedna + +Wersja 2 zakładała, że jedna warstwa przed aplikacją MCP ustawi wszystko. +**To było błędne dla IP klienta**: `ASGITransport` buduje scope wewnętrznego +żądania sam i wpisuje tam własne `client` (`httpx/_transports/asgi.py:92,117`). +Warstwa stojąca przed aplikacją MCP nie ma jak tego dosięgnąć. + +Potrzebne są więc dwie: + +**`KontekstMcp`** (przed aplikacją MCP) zdejmuje z zewnętrznego żądania i kładzie +w `ContextVar`: | Co | Po co | |---|---| -| `Host` | rozstrzygnięcie `Uczelnia` → bramka API i `ukryte_statusy` (§2.5) | -| scheme | `build_absolute_uri`, brak pętli `SECURE_SSL_REDIRECT` | -| IP klienta | throttling `SearchAnonThrottle` liczy po IP (§7.3) | -| bearer (jeśli jest) | przekazanie do wewnętrznego żądania | -| świeży słownik cache | izolacja `_slownik_cache()` per żądanie | +| `Host` | rozstrzygnięcie `Uczelnia` → bramka API i `ukryte_statusy` (§7.2) | +| scheme z `X-Forwarded-Proto` | inaczej `SECURE_SSL_REDIRECT` zapętla (§7.6) | +| IP z `get_client_ip` | throttling (§7.3) | +| bearer | przekazanie do wewnętrznego żądania | +| budżet czasu i współbieżności | D11 | + +**`KlientScope`** (wokół `django_asgi_app`, czyli **za** transportem) nadpisuje +`scope["client"]` wartością z ContextVar. To jedyne miejsce, w którym da się to +zrobić. + +Reset ContextVarów na wyjściu obowiązkowy. + +**Sufit czasu (D11).** Timeouty `httpx` są przy `ASGITransport` martwe, a +gunicornowy `timeout` to heartbeat. Jedno `tools/call` rozwija się na N żądań do +API bez żadnego limitu. `KontekstMcp` owija obsługę w `anyio.fail_after(...)` +i trzyma semafor ograniczający liczbę równoległych żądań wewnętrznych. Wartości +do ustalenia w planie, ale **istnienie limitu jest decyzją specu**, nie +„pomiarem na później". -Reset na wyjściu obowiązkowy — klient MCP żyje w lifespanie, czyli między -żądaniami, a `ContextVar` bez resetu wycieka do następnego. +### 5.3 `BppClient` per żądanie (D9) -### 5.3 `BppClientInProcess` +`BppClient` zapamiętuje `api_root` w `__init__`, `Config` jest `frozen`, a klient +z lifespanu jest jeden na proces (§2.2). Zamiast obchodzić to nadpisywaniem +prywatnego `_full_url`, korzystamy z furtki w kontrakcie `register_tools`: +lifespan oddaje **obiekt o tych samych atrybutach**, którego `client` jest +property: ```python -class BppClientInProcess(BppClient): - def __init__(self, config, **kw): - super().__init__( - config, - transport=httpx.ASGITransport( - app=django_asgi_app, # NIE `application` — rekurencja - raise_app_exceptions=False, # 500 → BppNetworkError, nie traceback - ), - tryb_auth=TrybAuth.W_PROCESIE, - max_retries=0, # nie ma sieci do ponowienia - **kw, - ) - - def _slownik_cache(self): - return cache_zadania.get() # ContextVar z §5.2 +class KontekstZadania: + """Spełnia kontrakt KontekstApp, ale klienta oddaje per żądanie.""" + + bearer_provider = None # host wielo-użytkownikowy — nigdy cache tokenu + + @property + def client(self) -> BppClient: + return klient_zadania.get() # ContextVar, ustawiony przez KontekstMcp ``` -`config.base_url` składamy **per żądanie** z `Host` i scheme zewnętrznego -żądania. Host `bpp.invalid` z pierwszej wersji jest wycofany (§4.1). +`KontekstMcp` zakłada klienta na czas żądania: -Odziedziczona mechanika: +```python +BppClientInProcess( + Config(base_url=f"{scheme}://{host}", transport="stdio"), + transport=httpx.ASGITransport( + app=KlientScope(django_asgi_app), + raise_app_exceptions=False, # 500 → BppNetworkError, nie traceback + ), + tryb_auth=TrybAuth.W_PROCESIE, + max_retries=0, +) +``` -| Element | W procesie | -|---|---| -| retry + backoff | **martwy** (`max_retries=0`) | -| timeouty `httpx` | **martwe** — `ASGITransport` ich nie czyta; sufit musi dać host | -| semafor (8) | **zostaje** — dławi równoległe rozwijanie hyperlinków | -| cache | **przekierowany** na słownik per-żądanie | -| auto-follow paginacji | **zostaje** | -| mapowanie błędów | **zostaje** | +Rozwiązuje naraz: **`_api_root` z właściwego hosta**, **cache izolowany** +(świeży klient = świeży słownik, szew `_slownik_cache` niepotrzebny), +**semafor per żądanie**. Koszt to `AsyncClient` na żądanie — bez puli połączeń +i bez TLS to praktycznie sam obiekt Pythona. ---- +`follow_redirects=False` (D12) — patrz §7.6. -## 6. Uwierzytelnianie i discovery +Odziedziczona mechanika: retry martwy (`max_retries=0`), timeouty martwe (sufit +z §5.2), auto-follow paginacji i mapowanie błędów bez zmian. -### 6.1 Przepływ +### 5.4 `BramkaBearera` — własna warstwa 401 (D10) -``` -brak Bearera → wewnętrzne żądanie bez Authorization → AnonymousUser -ważny Bearer → przekaż WYŁĄCZNIE ten nagłówek → StrictOAuth2Authentication -nieważny / zły scope → 401 + WWW-Authenticate -``` +SDK nie zna trybu „anonim dozwolony, zły token odrzucony" (§2.3): albo +`token_verifier` i 401 dla wszystkich bez tożsamości, albo brak weryfikacji +w ogóle. Piszemy więc własną warstwę ASGI **przed** aplikacją MCP, w dwóch +wariantach montażu: + +| Adres | Brak nagłówka | Zły / wygasły / bez scope | Ważny | +|---|---|---|---| +| `/mcp` | przepuść jako anonim | **401** + `WWW-Authenticate` | przepuść z tożsamością | +| `/mcp/auth` | **401** + `WWW-Authenticate` | **401** + `WWW-Authenticate` | przepuść z tożsamością | + +Weryfikacja tokenu to lookup w tej samej bazie — wzorzec z +`ApiReadOnlyForBearerMiddleware._ma_wazny_bearer` (`middleware.py:31-40`), +opakowany w `sync_to_async` (kontekst async). -### 6.2 Discovery +**Dlaczego dwa adresy.** `/mcp` nigdy nie zmusi klienta do logowania, bo anonim +dostaje 200 — a czy klienci MCP odpalają OAuth przy 401 na **późniejszym** +żądaniu, jest nieznane i zależne od implementacji. `/mcp/auth` odrzuca 401-ką +już `initialize`, czyli w jedynym momencie, co do którego mamy pewność, że +obsługuje go każdy klient. Użytkownik wybiera adres świadomie, a projekt +przestaje zależeć od cudzego zachowania. -Nowy widok w `oauth_mcp/views_metadata.py` — `/.well-known/oauth-protected-resource` -(RFC 9728), bliźniak istniejącego RFC 8414, ten sam wzorzec `build_absolute_uri`: +`WWW-Authenticate`: +`Bearer resource_metadata="https:///.well-known/oauth-protected-resource"` + +**`token_verifier` SDK nie jest używany** — jego montaż wyłączyłby anonima. + +--- + +## 6. Discovery + +Nowy widok w `oauth_mcp/views_metadata.py` — +`/.well-known/oauth-protected-resource` (RFC 9728), bliźniak istniejącego +RFC 8414, ten sam wzorzec `build_absolute_uri`: ```json { "resource": "https:///mcp", @@ -327,21 +411,27 @@ Nowy widok w `oauth_mcp/views_metadata.py` — `/.well-known/oauth-protected-res "bearer_methods_supported": ["header"] } ``` -Nagłówek przy 401: -`Bearer resource_metadata="https:///.well-known/oauth-protected-resource"` - Poprzedni spec (§6) przypisywał ten plik pakietowi `bpp-mcp`. **Przenosi się do BPP** — Resource Serverem jest instancja. -### 6.3 Allowlista hostów aplikacji MCP +### 6.1 Allowlista hostów aplikacji MCP -**[probe]** `TransportSecuritySettings` to druga, niezależna od Django lista -dozwolonych hostów; nieznany `Host` → 421. Zasilamy ją z `ALLOWED_HOSTS` -(i `DJANGO_BPP_HOSTNAMES` w instalacji wielouczelnianej). **Nie wyłączamy** — -to ochrona przed DNS-rebinding. +**[probe]** `TransportSecuritySettings` to druga, niezależna lista dozwolonych +hostów. Trzy rzeczy, których wersja 2 nie widziała: -Bez tego kroku wszystkie wdrożenia poza jednym dostaną 421 i nikt nie będzie -wiedział dlaczego. +1. **Inna semantyka niż `ALLOWED_HOSTS`** — dopasowanie dokładne albo `host:*` + (`transport_security.py:49-60`). Django-owe `.domena` i `*` **nie działają**; + trzeba je przetłumaczyć, a testy repo używają `ALLOWED_HOSTS = ["*"]`. +2. **Sprawdzany jest też `Origin`** (`:65-70`) — klienci przeglądarkowe + i MCP Inspector go wysyłają; pusta lista `allowed_origins` + obecny `Origin` + → 421. +3. **`host=` bez `transport_security` po cichu wyłącza ochronę** + (`lowlevel/server.py:738-744`). Dlatego **zawsze** przekazujemy jawne + `TransportSecuritySettings`. + +Lista budowana **fabryką aplikacji** (`build_application()`), nie przy imporcie +modułu — inaczej `settings.ALLOWED_HOSTS` nadpisane w teście nigdy do niej nie +dotrze (§11). --- @@ -350,142 +440,195 @@ wiedział dlaczego. ### 7.1 Przekazujemy WYŁĄCZNIE `Bearer` Allowlista nagłówków wewnętrznego żądania: **`Authorization` tylko gdy schemat -to `Bearer`**, plus `Accept`. Odcinamy jawnie `Cookie`, `X-Forwarded-*`, -`Referer` i wszystko inne. - -Dwa niezależne obejścia, gdyby przekazywać hurtem: +to `Bearer`**, plus `Accept` i `Content-Type`. Odcinamy `Cookie`, +`X-Forwarded-*`, `Referer` i resztę. -- **`Cookie`** → `SessionAuthentication` (`base.py:1058`) uwierzytelnia sesją. - Pełne uprawnienia zalogowanego, bez zgody, bez scope, bez revoke. CSRF nie - chroni, bo wykonujemy GET. +- **`Cookie`** → `SessionAuthentication` (`base.py:1058`) uwierzytelnia sesją: + pełne uprawnienia, bez zgody, bez scope, bez revoke. CSRF nie chroni, bo GET. - **`Authorization: Basic`** → `BasicAuthentication` (`base.py:1059`) - uwierzytelnia hasłem. Do tego `ApiReadOnlyForBearerMiddleware` sprawdza - **wyłącznie prefiks `bearer `** (`middleware.py:33`), więc dla Basica druga - warstwa read-only **nie istnieje**. + uwierzytelnia hasłem, a `ApiReadOnlyForBearerMiddleware` sprawdza **wyłącznie + prefiks `bearer `** (`middleware.py:33`) — więc dla Basica warstwa read-only + **nie istnieje**. + +### 7.2 Propagacja `Host` — to jest kontrola dostępu -Oba wymagają testu regresyjnego. +Wewnętrzne żądanie **musi** nieść `Host` zewnętrznego. Inaczej `Uczelnia=None` +(§2.5), a wtedy `BramkaApiV1` **przepuszcza bezwarunkowo** — API wyłączone przez +administratora staje się dostępne przez `/mcp` — a `ukryte_statusy=None` +przestaje filtrować rekordy ukrytych statusów. W instalacji wielouczelnianej to +wyciek między uczelniami. -### 7.2 Propagacja `Host` — to jest kontrola dostępu, nie kosmetyka +### 7.3 Throttling -Wewnętrzne żądanie **musi** nieść `Host` zewnętrznego. Inaczej `Uczelnia=None`, -a wtedy (§2.5): +`ASGITransport` ustawia `client=("127.0.0.1", 123)`, więc bez interwencji +wszyscy anonimowi dzielą jeden kubełek. `KlientScope` (§5.2) wstawia IP +z `get_client_ip`. **Nie dokładamy `X-Forwarded-For`** — DRF bez `NUM_PROXIES` +kluczowałby po całym nagłówku (§2.5), a my chcemy `REMOTE_ADDR`. -- `BramkaApiV1` **przepuszcza bezwarunkowo** — obejście wszystkich sześciu - przełączników, w tym `api_v1_wlaczone` i `api_v1_tylko_zalogowani`. API - wyłączone przez administratora staje się dostępne przez `/mcp`. -- `ukryte_statusy` → `None` → wyciekają rekordy ukrytych statusów korekty. -- W instalacji wielouczelnianej: nie wiadomo, czyje dane zwrócić. +Osobno: strefa nginx `bpp_api` (60 r/s) **jest omijana**, bo wewnętrzne żądania +nie przechodzą przez nginx, a jedno `tools/call` rozwija się na N żądań. Sufit +z D11 jest jedyną obroną na tej ścieżce — dlatego jest decyzją, nie pomiarem. -Test wielo-hostowy obowiązkowy. +### 7.4 Rekurencja i zakres URL-i -### 7.3 Throttling nie może zapaść się do jednego kubełka +`ASGITransport` dostaje `django_asgi_app`, nie `application`. `BppClientInProcess` +odrzuca ścieżki spoza `/api/v1/` **oraz sprawdza host** — `_full_url` przyjmuje +URL-e bezwzględne wprost (`client.py:183-184`), a paginacyjne `next` je niosą. -`ASGITransport` ustawia `client=("127.0.0.1", 123)`. `SearchAnonThrottle` liczy -po IP → **wszyscy anonimowi użytkownicy MCP dzieliliby jeden limit**, czyli -jeden klient DoS-uje wszystkich. `KontekstMcpMiddleware` wstawia do wewnętrznego scope'u -`client` = IP rozstrzygnięte z zewnętrznego żądania. +### 7.5 Widoczność i Rollbar -IP bierzemy przez istniejące `django_bpp.client_ip.get_client_ip` (używa go już -`views_dcr.py`), **nie** przez ślepe przepisanie `X-Forwarded-For`. +Wewnętrzne żądania nie trafiają do access-logu nginx ani uvicorna. Logujemy je +w `KontekstMcp` (ścieżka, kod, czas, obecność bearera — **nigdy token**). -Osobno: strefa nginx `bpp_api` (60 r/s) **jest omijana** — wewnętrzne żądania -nie przechodzą przez nginx, a jedno wywołanie `pobierz_rekord` rozwija się na -N żądań API. Kierunek ryzyka jest odwrotny do tego, co pisała pierwsza wersja -specu (obawa o zbyt ciasny limit); realnie limit **nie działa** na tej ścieżce. +`CustomRollbarNotifierMiddleware` (`base.py:346`) **nie obejmuje** wyjątków +z `LifespanMcp`, `KontekstMcp` ani aplikacji MCP — te nie trafią do Rollbara +same. Warstwa MCP woła `rollbar.report_exc_info()` jawnie i **nie wkłada +nagłówków do `extra_data`**. (`"authorization"` jest w `ROLLBAR_SCRUB_FIELDS`, +`base.py:1777`, ale to dotyczy żądań Django — zamyka §15.7.) -### 7.4 Rekurencja +### 7.6 Przekierowania — `follow_redirects=False` (D12) -`ASGITransport` dostaje `django_asgi_app`, nie `application`. Dodatkowo -`BppClientInProcess` odrzuca ścieżki spoza `/api/v1/`. +`BppClient` domyślnie podąża za przekierowaniami (`client.py:153`). W procesie +daje to dwa ciche problemy: -### 7.5 Widoczność +1. **`SECURE_SSL_REDIRECT=True`** (`production.py:168`) + scheme `http` → 301 na + `https` → httpx podąża → **każde żądanie wewnętrzne wykonuje się dwa razy**, + niewidocznie. Propagacja scheme (§5.2) tego unika, ale `follow_redirects` + maskowałoby błąd, gdyby propagacja przestała działać. +2. **`FirstRunWizardMiddleware`** — `DEFAULT_SKIP_PREFIXES` to `/static/`, + `/media/`, `/__debug__/`, `/admin/` (`first_run_wizard/middleware.py:22-27`), + a `FIRST_RUN_WIZARD_SKIP_PREFIXES` nie jest ustawione. Na świeżej instalacji + wewnętrzne `/api/v1/` **dostaje przekierowanie do kreatora**, httpx za nim + idzie, `resp.json()` na HTML-u rzuca `ValueError`, którego `_request` nie + mapuje (`client.py:271`) → surowy traceback w narzędziu. **To zamyka §15.6: + tak, przekierowuje.** -Wewnętrzne żądania nie trafiają do access-logu nginx ani uvicorna — ruch API -generowany przez MCP jest **niewidoczny dla dotychczasowego monitoringu**. -Logujemy je po stronie `KontekstMcpMiddleware` (ścieżka, kod, czas, obecność -bearera — **nigdy sam token**). +Przy `follow_redirects=False` oba przypadki kończą się czytelnym `BppError` ze +statusem 301/302. + +### 7.7 Co jeszcze wypada przez ominięcie middleware + +Przegląd `MIDDLEWARE` (`base.py:328-351`) dla **zewnętrznego** `/mcp`: + +| Middleware | Skutek | +|---|---| +| `SecurityMiddleware` | `/mcp` bez HSTS i nagłówków bezpieczeństwa — dokłada je nginx | +| `MaliciousRequestBlockingMiddleware` | `/mcp` poza filtrem botów — nowa powierzchnia bez tej siatki | +| `CountdownBlockingMiddleware` | **nie zwalnia `/api/`** (`django_countdown/middleware.py:22`): w trybie odliczania wewnętrzne żądania dostaną HTML blokady, a `/mcp` nie będzie blokowany. Decyzja: `/mcp` odbijamy **razem** z resztą serwisu | +| `FirstRunWizardMiddleware` | §7.6 | +| `CustomRollbarNotifierMiddleware` | §7.5 | +| `Session`/`CSRF`/`Auth`/`SessionSecurity`/`Axes` | nieistotne **pod warunkiem D6** | +| `LocaleMiddleware` | komunikaty narzędzi w domyślnym języku — kosmetyka | --- ## 8. Strona `/mcp/` dla człowieka -`GET /mcp/` (ze slashem) → widok Django z instrukcją podłączenia; `POST /mcp` → -protokół. Rozdzielenie po ścieżce, nie po metodzie (§5.1). - -Treść wzorowana na `mcp.sentry.dev`: adres złącza jako główna treść, jedno -zdanie o dostępie, gotowe wklejki dla klientów, link do `bpp-mcp` dla stdio. -Adres składany przez `build_absolute_uri` — każda uczelnia widzi swój. -Szablon podlega `LocaleMiddleware`, więc teksty przez `gettext`. +`GET /mcp/` → widok Django z instrukcją. `GET /mcp` z `Accept: text/html` → +redirect na `/mcp/` (§5.1). Treść wzorowana na `mcp.sentry.dev`: **oba adresy** +(publiczny i z logowaniem) z wyjaśnieniem różnicy, gotowe wklejki dla klientów, +link do `bpp-mcp` dla stdio. Adresy przez `build_absolute_uri`. Teksty przez +`gettext` (`LocaleMiddleware` działa dla tej ścieżki — idzie przez Django). --- ## 9. Zależności i obraz -Dochodzą do `pyproject.toml`: +Dochodzi `bpp-mcp>=0.4,<0.5`. **Delta policzona** przeciwko `uv.lock` BPP — +dziesięć nowych pakietów: -``` -bpp-mcp>=0.4,<0.5 # narzędzia + szwy -``` +| Nowe | Uwaga | +|---|---| +| `httpx` | klient `BppClient` | +| **`httpx2`** | `mcp` 2.0 przeszło na drugą bibliotekę HTTP | +| `pydantic` | kompilowana, spora | +| `opentelemetry-api`, `jsonschema`, `mcp-types`, `python-multipart`, `sse-starlette`, `starlette`, `typing-inspection` | | + +Już obecne: `anyio` (4.11.0), `pyjwt`, `uvicorn`. -co ciągnie `mcp>=2,<3`, `httpx` i `starlette`. **Żadnego z nich nie ma dziś -w `uv.lock` BPP.** Konsekwencje do sprawdzenia przed scaleniem: +**Kolizji `starlette` nie ma** — `channels[daphne]` ciągnie `asgiref` i `daphne`, +nie `starlette`. To zamyka §15.2. +Do sprawdzenia przed scaleniem: +- czy `mcp 2.0` nie wymaga `anyio` nowszego niż 4.11.0 (venv `bpp-mcp` ma 4.14.1), - rozmiar obrazu produkcyjnego, -- bramka Trivy w `build-docker-images.yml` (nowe pakiety = nowe CVE), -- czy `starlette` nie koliduje z wersją ciągniętą przez `channels[daphne]`. +- **bramka Trivy** — dziesięć nowych pakietów to dziesięć nowych źródeł CVE + w łańcuchu; `python-multipart` ma historię. + +**`httpx` i `httpx2` naraz** to dublet, nie konflikt. Najczystsze wyjście wraca +do projektu 2: `bpp-mcp` przechodzi na `httpx2` (0.5.0) i przestaje wozić drugi +klient — pod warunkiem, że `httpx2` ma `ASGITransport` o tej samej semantyce, +bo na niej stoi D4. Poza zakresem tego specu (§13). --- ## 10. Wdrożenie -Wbrew pierwszej wersji specu, **`bpp-deploy` wymaga zmian**: +`bpp-deploy` **wymaga zmian**: -1. **ModSecurity/CRS.** Wyłączenie obejmuje dziś tylko - `^/(bpp|api/v1)/zapytanie/`. Ciało JSON-RPC na `/mcp` — zwłaszcza - z zapytaniem DjangoQL w argumentach narzędzia — będzie skanowane przez CRS - i 403 z WAF-a jest bardzo prawdopodobny. Potrzebne wyłączenie dla `/mcp`, - zawężone jak istniejące. -2. **Strefa `limit_req`.** `/mcp` wpada dziś pod `location /` (100 r/s burst - 100). Do zmierzenia; jeśli agent się o to obija — własna strefa. +1. **ModSecurity/CRS** — wyłączenie obejmuje dziś tylko + `^/(bpp|api/v1)/zapytanie/`. Ciało JSON-RPC na `/mcp`, zwłaszcza + z DjangoQL w argumentach, będzie skanowane i 403 z WAF-a jest bardzo + prawdopodobny. Potrzebne wyłączenie dla `/mcp`, zawężone jak istniejące. +2. **Strefa `limit_req`** — `/mcp` wpada dziś pod `location /` (100 r/s). Sufit + z D11 działa **wewnątrz** aplikacji; strefa nginx chroni przed zalewem samych + żądań MCP. Do zmierzenia, ale nie jest to już „poza zakresem" (§7.3). -Bez zmian: `stateless_http` sprawia, że recykling `--max-requests` jest -nieszkodliwy i sticky sessions nie są potrzebne; `json_response` znosi problem -`proxy_read_timeout`. +Bez zmian: `stateless_http` czyni recykling `--max-requests` nieszkodliwym, +`json_response` znosi problem `proxy_read_timeout`. --- ## 11. Testy Konwencja repo: pytest, bez `unittest.TestCase`, `model_bakery.baker`. - -**Routing ASGI** -- scope `lifespan` → `startup.complete`; błąd startu → `startup.failed` - (nie cisza) -- `POST /mcp` przed lifespanem → kontrolowany błąd, nie `RuntimeError` -- ruch spoza `/mcp` trafia do Django; `websocket` do channels -- `GET`/`DELETE` na `/mcp` trafiają do aplikacji MCP (nie do Django) - -**Bezpieczeństwo (obowiązkowe, każdy osobno)** -- `Cookie` wysłany do `/mcp` **nie** uwierzytelnia (§7.1) -- `Authorization: Basic` **nie** jest przekazywany (§7.1) -- wewnętrzne żądanie niesie `Host` zewnętrznego; **test wielo-hostowy**: - dwie `Uczelnia`, dwa hosty, każdy widzi swoje (§7.2) -- `api_v1_wlaczone=False` → `/mcp` też odmawia (dowód, że bramka działa) -- rekord o ukrytym statusie nie wycieka przez `/mcp` -- dwa różne IP → dwa kubełki throttlingu (§7.3) -- ścieżka spoza `/api/v1/` odrzucona (§7.4) +**Repo nie ma `pytest-asyncio`** — korutyny odpala się wzorcem z +`django_bpp/tests/test_asgi_notifications.py:27` (`asyncio.run` w osobnym +wątku), więc dostęp do bazy wymaga `transaction=True` albo tego samego wzorca. + +**Aplikacja musi być budowana fabryką** `build_application()`, nie tworzona przy +imporcie: (i) allowlista hostów SDK liczona z `settings` przy imporcie nie +zobaczy override'u z fixture'a `settings` (§6.1); (ii) +`session_manager.run()` można wejść **raz na instancję**, więc modułowe +`application` nie nadaje się do wielokrotnego lifespanu w testach. + +**Routing i lifespan** +- klucz `"lifespan"` → `startup.complete`; błąd startu → `startup.failed` +- **leniwy start**: żądanie do `/mcp` **bez** lifespanu (symulacja Daphne) + startuje menedżera i zwraca 200 — plus test, że drugie żądanie **nie** + próbuje startować ponownie +- ruch spoza `/mcp` do Django; `websocket` do channels +- `GET /mcp` z `Accept: text/html` → redirect na `/mcp/` +- `GET /mcp` bez tego nagłówka przy `stateless_http` → **405** (nie SSE) + +**Bezpieczeństwo (każdy osobno)** +- `Cookie` wysłany do `/mcp` **nie** uwierzytelnia — także po wewnętrznym + przekierowaniu (httpx ma własny cookie-jar, `_client.py:566`) +- `Authorization: Basic` **nie** jest przekazywany +- **test wielo-hostowy**: fixture'y są (`src/fixtures/conftest_multisite.py:16-48`, + rejestracja `conftest.py:61`); domeny wymagają nadpisania `ALLOWED_HOSTS` + per test (wzorzec: `admin_dashboard/tests/test_cache_vary_host.py:46`) — dwie + `Uczelnia`, dwa hosty, każdy widzi swoje +- `api_v1_wlaczone=False` → `/mcp` też odmawia +- rekord o ukrytym statusie nie wycieka +- dwa różne IP → dwa kubełki throttlingu +- ścieżka **i host** spoza `/api/v1/` odrzucone (§7.4) +- `/mcp` bez tokenu → 200; `/mcp/auth` bez tokenu → **401** z `WWW-Authenticate`; + zły token na obu → **401** **Klient** -- `BppClientInProcess` zwraca to samo, co żądanie sieciowe dla tej ścieżki -- cache izolowany między żądaniami (dwa żądania, różne konteksty) -- wyjątek aplikacji → `BppNetworkError`, nie surowy traceback +- `BppClientInProcess` zwraca to samo, co żądanie sieciowe +- cache izolowany między żądaniami +- przekierowanie kreatora → czytelny `BppError`, nie `ValueError` (§7.6) +- przekroczenie sufitu czasu → kontrolowany błąd (D11) **Protokół** -- `initialize` → `tools/list` → `tools/call` na żywej aplikacji, bez sieci -- 11 narzędzi + 1 prompt widoczne +- `initialize` → `tools/list` → `tools/call`, bez sieci; 11 narzędzi + 1 prompt +- klient testowy musi wysyłać `Content-Type: application/json` + (`transport_security.py:96-100`) -**Ręcznie, do odnotowania w planie** -- podłączenie prawdziwego klienta Claude i pełny taniec OAuth +**Ręcznie** +- podłączenie prawdziwego klienta Claude do obu adresów i pełny taniec OAuth --- @@ -493,15 +636,16 @@ Konwencja repo: pytest, bez `unittest.TestCase`, `model_bakery.baker`. - `POST /mcp` odpowiada na `initialize`, `tools/list`, `tools/call` bez tokenu, zwracając dane publiczne identyczne z `/api/v1/`. -- Ważny bearer → narzędzie widzi tożsamość użytkownika. -- Nieważny bearer → **401** z `WWW-Authenticate` wskazującym PRM. -- `Cookie` i `Basic` wysłane do `/mcp` **nie** uwierzytelniają. +- `POST /mcp/auth` bez tokenu → **401** z `WWW-Authenticate` wskazującym PRM. +- Zły token na obu adresach → **401**. +- `Cookie` i `Basic` **nie** uwierzytelniają. - Dwie uczelnie na dwóch hostach widzą **swoje** dane; `api_v1_wlaczone=False` blokuje również `/mcp`. +- **`/mcp` działa pod Daphne** (`run-site run`) — leniwy start. - `/.well-known/oauth-protected-resource` poprawne przy wielu hostach. -- `GET /mcp/` renderuje stronę z adresem właściwym dla hosta. -- Narzędzia pochodzą z `bpp_mcp` — **żadnej kopii** w `src/`. -- `stateless_http=True`, `json_response=True`, brak długożyjących strumieni. +- `GET /mcp/` renderuje stronę z oboma adresami właściwymi dla hosta. +- Narzędzia z `bpp_mcp` — **żadnej kopii** w `src/`. +- Jawny sufit czasu i współbieżności działa (D11). - Testy zielone; `ruff` czysty; `pre-commit` bez uwag. - Newsfragment w `src/bpp/newsfragments/`. - Istniejące testy `oauth_mcp` i `api_v1` przechodzą **bez modyfikacji**. @@ -510,52 +654,71 @@ Konwencja repo: pytest, bez `unittest.TestCase`, `model_bakery.baker`. ## 13. Poza zakresem -- Narzędzia mutujące (zapis przez MCP). -- **Rozszerzenie allowlisty DCR** o ChatGPT/Cursor — osobna decyzja - bezpieczeństwa (§1.1). -- Konsolidacja reguł tokenu w `oauth_mcp/tokens.py` — projekt 3 (§3). -- WebMCP jako API przeglądarki (`navigator.modelContext`). +- Narzędzia mutujące. +- **Rozszerzenie allowlisty DCR** o ChatGPT/Cursor — osobna decyzja (§1.1). +- Konsolidacja reguł tokenu — projekt 3 (§3). +- **Przejście `bpp-mcp` na `httpx2`** (0.5.0) — osobne wydanie (§9). +- WebMCP jako API przeglądarki. - Wygaszenie `bpp-mcp`. -- Zasoby (`resources`) MCP. Prompt `zloz_zapytanie_djangoql` wchodzi, bo - `register_tools` rejestruje go razem z narzędziami — pierwsza wersja specu - wykluczała prompty i jednocześnie wymagała braku kopii, co było sprzeczne. -- Własna strefa `limit_req` — dopiero po pomiarze (§10). -- RFC 8707 / audience binding — dziedziczone odstępstwo. +- Zasoby (`resources`) MCP. Prompt `zloz_zapytanie_djangoql` **wchodzi**, bo + `register_tools` rejestruje go razem z narzędziami. +- RFC 8707 / audience binding. + +Strefa `limit_req` **przestaje** być poza zakresem — patrz §10.2. --- -## 14. Czym ta wersja różni się od pierwszej +## 14. Rozliczenie z recenzjami + +**Wersja 1 → 2** (pierwsza recenzja): bloker lifespanu potwierdzony, ale +rozwiązywalny; bloker hosta potwierdzony i groźniejszy (kontrola dostępu, nie +błąd 400); dodane `Basic`, cache, throttling, ModSecurity, allowlista DCR; +poprawione 7 → 11 narzędzi; zakres rozbity na trzy projekty. + +**Wersja 2 → 3** (druga recenzja): -| Ustalenie recenzji | Werdykt po weryfikacji | +| Ustalenie | Rozstrzygnięcie | |---|---| -| Bloker: lifespan menedżera sesji | **potwierdzony** [probe], ale rozwiązywalny dopisaniem jednego klucza do `ProtocolTypeRouter` (§5.1) — nie wymaga przeprojektowania | -| Bloker: host `bpp.invalid` | **potwierdzony i groźniejszy, niż zgłoszono** — to obejście kontroli dostępu, nie tylko 400 (§7.2) | -| `Cookie` → `SessionAuthentication` | potwierdzony; **dodatkowo `Basic`**, którego pierwsza wersja nie widziała (§7.1) | -| Cache współdzielony | potwierdzony; rozwiązany szwem z `bpp-mcp` 0.4.0 (§5.3) | -| Throttling w jednym kubełku | potwierdzony; kierunek ryzyka w pierwszej wersji był **odwrotny** (§7.3) | -| ModSecurity | potwierdzony — `bpp-deploy` jednak wymaga zmian (§10) | -| Allowlista DCR | potwierdzona — obietnica zasięgu była nieprawdziwa (§1.1) | -| 7 narzędzi z `tools.py` | **błąd** — 11 narzędzi + prompt, wrappery z `server.py` (§2.2) | -| Zakres | rozbity na trzy projekty (§3) | - -Nowe, spoza recenzji — z probe'ów: `stateless_http`/`json_response` przeniesione -do `streamable_http_app()`, `json_response` znoszące SSE, oraz **własna -allowlista hostów aplikacji MCP** (§6.3), o której nie wiedziała ani pierwsza -wersja specu, ani recenzja. +| **B1** `base_url` per żądanie niewykonalne z 0.4.0 | **D9** — klient per żądanie przez property w obiekcie lifespanu; zero zmian w `bpp-mcp` (§5.3) | +| **B2** lifespan nie działa pod Daphne (dev, Playwright) | **D2** — leniwy start idempotentny, wyzwalany z obu stron (§5.1) | +| **B3** brak wykonawcy 401 + PRM; `token_verifier` kasuje anonima | **D10** — własna `BramkaBearera` i **dwa adresy** (§5.4) | +| IP klienta nieosiągalne przed aplikacją MCP | `KlientScope` za transportem (§5.2) | +| scheme i `SECURE_SSL_REDIRECT` | propagacja + `follow_redirects=False` (§7.6) | +| semantyka allowlisty SDK, `Origin`, `host=` bez `transport_security` | §6.1 | +| `FirstRunWizardMiddleware` przekierowuje | potwierdzone, **zamyka §15.6** (§7.6) | +| brak sufitu czasu | **D11** (§5.2) | +| `GET /mcp` → 406 | redirect przy `Accept: text/html` (§5.1) | +| Rollbar nie obejmuje warstwy MCP | jawne `report_exc_info` (§7.5), **zamyka §15.7** | +| throttling po całym XFF | nie dokładamy XFF (§7.3) | +| `startup.failed` → boot-loop | nazwane jako oczekiwane (§5.1) | +| testy: fabryka, brak `pytest-asyncio`, `run()` raz | §11 | +| `stateless_http` + GET → 405, nie SSE | §11 | +| numery linii `views_dcr`, `channels/routing` | poprawione (§2.1, §2.4) | +| `CountdownBlockingMiddleware` nie zwalnia `/api/` | §7.7 | + +**Nieprawdziwe twierdzenia wersji 2, wycofane:** „szew cache rozwiązuje problem +współdzielenia" (rozwiązywał połowę — `_api_root` został, D9 rozwiązuje oba); +„throttling potwierdzony" (problem tak, mechanizm naprawy nie istniał); +„lifespan rozwiązany jednym kluczem" (tylko pod uvicornem). --- -## 15. Otwarte kwestie do rozstrzygnięcia w planie - -1. **Czy klienci MCP odpalają OAuth przy 401 na kolejnym żądaniu, czy tylko - przy `initialize`?** Jeśli tylko przy `initialize`, endpoint anonimowy nigdy - nie zaproponuje logowania i potrzebny jest drugi adres (np. `/mcp/auth`). - **Do weryfikacji empirycznej na żywym kliencie.** Częściowo łagodzi to - istniejąca hybrydowa podpowiedź w treści odpowiedzi narzędzi. -2. Czy `starlette` z `bpp-mcp` koliduje z wersją z `channels[daphne]` (§9). -3. Wpływ nowych zależności na bramkę Trivy (§9). -4. Czy `limit_req` faktycznie boli (§10). -5. Kształt reguły wyłączającej ModSecurity dla `/mcp` (§10). -6. Czy `FirstRunWizardMiddleware` przekierowuje wewnętrzne `/api/v1/` na - świeżej instalacji — niezweryfikowane bez uruchomienia. -7. Czy Rollbar scrubuje `Authorization` dla wyjątków z wewnętrznego scope'u. +## 15. Otwarte kwestie + +Zamknięte w tej wersji: **§15.1** (kształt endpointu → D10), **§15.2** +(`starlette` — brak kolizji, §9), **§15.6** (kreator przekierowuje — tak, §7.6), +**§15.7** (Rollbar — §7.5). + +Zostają: + +1. Czy `mcp 2.0` wymaga `anyio` nowszego niż 4.11.0 z `uv.lock` (§9). +2. Wpływ dziesięciu nowych pakietów na bramkę Trivy i rozmiar obrazu (§9). +3. Wartości sufitu czasu i współbieżności (D11) — do ustalenia pomiarem, ale + **istnienie limitu jest już zdecydowane**. +4. Kształt reguły wyłączającej ModSecurity dla `/mcp` (§10.1). +5. Czy strefa `limit_req` dla `/mcp` jest potrzebna (§10.2). +6. Czy `httpx2` ma `ASGITransport` o semantyce wymaganej przez D4 — warunek + wstępny dla przejścia `bpp-mcp` na jeden klient HTTP (§9, §13). +7. **Trwałość grupy zadań anyio przy leniwym starcie** — `run()` wchodzony + z kontekstu żądania, a `AsyncExitStack` trzymany w obiekcie modułowym. + Do potwierdzenia testem pod Daphne (§5.1, §11). From 0fb6b16e2019ca4fb541303a33fa24ed48f55bf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sun, 6 Sep 2026 13:51:43 +0200 Subject: [PATCH 05/29] =?UTF-8?q?docs(spec):=20wersja=204=20=E2=80=94=20tr?= =?UTF-8?q?zy=20blokery=20z=20trzeciej=20recenzji?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU --- ...6-09-05-mcp-hostowany-w-serwisie-design.md | 368 +++++++++++++----- 1 file changed, 267 insertions(+), 101 deletions(-) diff --git a/docs/superpowers/specs/2026-09-05-mcp-hostowany-w-serwisie-design.md b/docs/superpowers/specs/2026-09-05-mcp-hostowany-w-serwisie-design.md index 9ae7b3cb2..cd703f3ab 100644 --- a/docs/superpowers/specs/2026-09-05-mcp-hostowany-w-serwisie-design.md +++ b/docs/superpowers/specs/2026-09-05-mcp-hostowany-w-serwisie-design.md @@ -1,14 +1,14 @@ # Hostowany serwer MCP w serwisie BPP (`/mcp`) — projekt 1 z 3 -Data: 2026-09-05 (wersja 3, 2026-09-06) +Data: 2026-09-05 (wersja 4, 2026-09-06) Status: projekt do zatwierdzenia Poprzednik: `2026-07-11-mcp-oauth-authorization-design.md` (warstwa OAuth po stronie BPP — **zrealizowana**, `src/oauth_mcp/`) -> **Historia dokumentu.** Wersja 1 dostała werdykt „wymaga przeprojektowania". -> Wersja 2 (przepisana po pierwszej recenzji i dwóch probe'ach) dostała -> „niewdrażalne — trzy blokery". Ta wersja zamyka B1–B3 i dwanaście poprawek. -> Rozliczenie w §14. +> **Historia dokumentu.** Wersja 1: „wymaga przeprojektowania". Wersja 2: +> „niewdrażalne — trzy blokery" (B1–B3). Wersja 3 zamknęła B1, ale wprowadziła +> trzy nowe blokery we własnych rozwiązaniach B2/B3 (BL-1…BL-3). Ta wersja +> zamyka BL-1…BL-3 i jedenaście poprawek. Rozliczenie w §14. --- @@ -95,8 +95,22 @@ atrybutach**" (`server.py:291-315`) — i to jest furtka, z której korzystamy. (`lowlevel/server.py:793-806`) — i wtedy odrzuca 401-ką **każde** żądanie bez tożsamości (`bearer_auth.py:93-97`). Czyli SDK nie zna trybu „anonim dozwolony, zły token odrzucony". Stąd D10 (§5.4). -- `stateless_http=True` + `GET` → **405** (`streamable_http.py:788-790,848`), - nie SSE. +- `SessionMessage.metadata.request` niesie do narzędzia **cały obiekt + `Request` zewnętrznego żądania** (`streamable_http.py:608-611`), z `Cookie` + włącznie. Dziś czyta z niego tylko `bearer_from_request` (`bpp_mcp/auth.py:46-53`), + ale D6 stoi przez to na **dyscyplinie kodu w `bpp-mcp`**, nie na izolacji + transportowej (§7.1). +- SDK zamienia każdy wyjątek narzędzia na `CallToolResult(is_error=True)` + (`mcpserver/server.py:423-424`) — wyjątki narzędzi **nie docierają** do + warstwy ASGI (§7.5). +- Dwie ścieżki protokołu mają **różny model czasu życia**: „modern" + (`MCP-Protocol-Version` spoza `HANDSHAKE_PROTOCOL_VERSIONS`, `manager:183-187`) + wykonuje handler w zadaniu żądania, „legacy" — w zadaniu grupy menedżera. + Budżet czasu musi działać w obu (§5.2). +- Czy `stateless_http=True` + `GET` daje 405, **nie zostało potwierdzone** — + wersja 3 cytowała `streamable_http.py:788-790,848`, ale 788-790 dotyczy + DELETE, a 848 to generyczna obsługa nieobsługiwanej metody. Do sprawdzenia + empirycznego, zanim powstanie test (§11). - Lifespan wchodzi **raz na serwer**; `session_manager.run()` można wejść **raz na instancję** (`streamable_http_manager.py:137-143`). @@ -142,10 +156,21 @@ z docstringu: > mapowania Site→Uczelnia) → **przepuszczamy**, bo nie ma kto podjąć decyzji `Uczelnia` rozstrzyga się z nagłówka `Host` (`SiteResolutionMiddleware`, -`Uczelnia.objects.get_for_request`). Czyli **request z nieznanym hostem omija -wszystkie sześć przełączników API**, a `UkryjStatusyKorektyMixin.ukryte_statusy` -daje `None` (`viewsets/common.py:9-13`) → rekordy ukrytych statusów przestają -być filtrowane. +`Uczelnia.objects.get_for_request`). + +**Zakres zagrożenia — sprostowanie wobec wersji 3.** Nie jest tak, że nieznany +host zawsze daje `Uczelnia=None`: `_site_dla_requestu` spada na `SITE_ID` +(`uczelnia.py:97-104`), a `uczelnia_dla_site` na „jedyną-albo-None" +(`:126-129`). W instalacji **jednouczelnianej** nieznany host trafi więc na +właściwą uczelnię. Obejście bramki zachodzi w instalacji **wielouczelnianej** +(>1 `Uczelnia`) albo bez `SITE_ID`. + +Tam jednak skutek jest dokładnie taki, jak opisano: `BramkaApiV1` przepuszcza +bezwarunkowo, omijając wszystkie sześć przełączników, a +`UkryjStatusyKorektyMixin.ukryte_statusy` daje `None` +(`viewsets/common.py:9-13`) → rekordy ukrytych statusów przestają być +filtrowane. D5 zostaje bez zmian — po prostu uzasadnienie dotyczy +wielouczelnianości, nie każdego wdrożenia. `SearchAnonThrottle` dziedziczy po `AnonRateThrottle`. Bez `NUM_PROXIES` (nie ma go w settings) DRF kluczuje po **całym** `X-Forwarded-For`, gdy jest @@ -174,7 +199,7 @@ uwierzytelniania**, a wrzucanie jej do środka feature'a było błędem. | # | Decyzja | Uzasadnienie | |---|---|---| | D1 | Narzędzia z `bpp-mcp`, zero kopii w `src/` | jedna implementacja | -| D2 | Klucz `"lifespan"` w istniejącym `ProtocolTypeRouter` **+ leniwy start** | klucz naprawia uvicorna; leniwy start naprawia Daphne (§2.4, §5.1) | +| D2 | Klucz `"lifespan"` w istniejącym `ProtocolTypeRouter` **+ leniwy start w osobnym zadaniu** | klucz naprawia uvicorna; leniwy start naprawia Daphne; osobne zadanie jest warunkiem poprawności, nie optymalizacją (§2.4, §5.1) | | D3 | `stateless_http=True` + `json_response=True` | recykling workera zabiłby sesję stateful; `json_response` znosi SSE | | D4 | Dane przez `httpx.ASGITransport` na `django_asgi_app` | ta sama ścieżka kodu, bez gniazda | | D5 | Wewnętrzne żądanie dziedziczy `Host`, scheme i IP klienta | inaczej `Uczelnia=None` → obejście bramki i wyciek (§7.2) | @@ -182,8 +207,8 @@ uwierzytelniania**, a wrzucanie jej do środka feature'a było błędem. | D7 | Polityka dostępu dziedziczona z `/api/v1/` | anonim czyta publiczne, bearer odblokowuje resztę | | D8 | Read-only | narzędzia wykonują wyłącznie GET | | **D9** | **`BppClient` per żądanie**, przez property w obiekcie lifespanu | `_api_root` zapamiętany w `__init__` (§2.2); rozwiązuje też cache i semafor | -| **D10** | **Dwa adresy: `/mcp` (publiczny) i `/mcp/auth` (zawsze 401 bez tokenu)** | SDK nie zna trybu „anonim tak, zły token nie" (§2.3); dwa adresy usuwają zależność od nieznanego zachowania klientów | -| **D11** | **Jawny sufit czasu i współbieżności** w naszej warstwie | timeouty `httpx` martwe przy `ASGITransport`, gunicornowy `timeout` to heartbeat (§2.4) | +| **D10** | **Dwa adresy: `/mcp` (publiczny) i `/mcp/auth` (zawsze 401 bez tokenu)**, z przepisaniem ścieżki | SDK montuje `Route`, nie `Mount`, więc drugi adres wymaga przepisania (§5.1); SDK nie zna trybu „anonim tak, zły token nie" (§2.3); dwa adresy usuwają zależność od nieznanego zachowania klientów | +| **D11** | **Jawny sufit czasu i współbieżności — egzekwowany w `BppClientInProcess`** | timeouty `httpx` martwe przy `ASGITransport`, gunicornowy `timeout` to heartbeat (§2.4) | | **D12** | `follow_redirects=False` w kliencie w procesie | inaczej `SECURE_SSL_REDIRECT` po cichu podwaja każde żądanie, a kreator instalacji przekierowuje na HTML (§7.6) | --- @@ -244,53 +269,97 @@ warstwy bezpieczeństwa po to, żeby naprawić rzecz, która jej nie dotyczy. Patchowanie `channels` odrzucone: jego zachowanie nie jest błędne, a monkeypatch byłby niewidoczny w `asgi.py` i pękłby przy aktualizacji. -**Leniwy start.** Sam klucz naprawia uvicorna i **nie naprawia Daphne**, pod -którym chodzi `run-site run` i `channels_live_server` (§2.4). Dlatego start -menedżera sesji jest **idempotentny i wyzwalany z obu stron**: +**Leniwy start — w osobnym, długowiecznym zadaniu.** Sam klucz naprawia +uvicorna i **nie naprawia Daphne**, pod którym chodzi `run-site run` +i `channels_live_server` (§2.4). + +Wersja 3 kazała wchodzić w `session_manager.run()` **z zadania żądania**. To +było błędne i jest przyczyną blokera BL-3 trzeciej recenzji: +`run()` to `async with lifespan, anyio.create_task_group()` +(`streamable_http_manager.py:145`), więc zadanie, które w to wchodzi, staje się +**host taskiem** grupy. Konsekwencje: + +- jeśli w tym zadaniu jest aktywny **jakikolwiek inny cancel scope** (a §5.2 + każe owijać obsługę w `fail_after`), wyjście z niego rzuci + `RuntimeError: Attempted to exit a cancel scope that isn't the current + task's current cancel scope` (`anyio/_backends/_asyncio.py:470-474`). + Pierwsze żądanie pada, grupa jest już oznaczona jako wystartowana + (`_has_started`, `manager:143`), więc **każde kolejne też pada**; +- anulowanie host taska (timeout, `application_close_timeout` Daphne, + `daphne/server.py:281-289`) **zatruwa grupę** — późniejsze `start()` kończy + się `RuntimeError("Child exited without calling task_status.started()")`. + Bez restartu procesu nie ma wyjścia, bo `run()` wchodzi się raz na instancję. + +Dlatego host taskiem musi być **zadanie tła**, nigdy żądanie: ```python class StartMcp: - """Wejście w session_manager.run() dokładnie raz, skądkolwiek przyjdzie.""" + """Wejście w session_manager.run() dokładnie raz, w OSOBNYM zadaniu.""" def __init__(self, mcp_app): self._app = mcp_app - self._lock = anyio.Lock() - self._stack: AsyncExitStack | None = None + self._gotowe = asyncio.Event() + self._zadanie: asyncio.Task | None = None + self._blad: BaseException | None = None async def zapewnij(self) -> None: - if self._stack is not None: - return - async with self._lock: - if self._stack is not None: # double-check pod blokadą - return - stack = AsyncExitStack() - await stack.enter_async_context( - self._app.router.lifespan_context(self._app) - ) - self._stack = stack + if self._zadanie is None: + self._zadanie = asyncio.get_running_loop().create_task(self._trzymaj()) + await self._gotowe.wait() + if self._blad is not None: + raise RuntimeError("Menedżer sesji MCP nie wstał") from self._blad + + async def _trzymaj(self) -> None: + """Host task grupy zadań — żyje tyle, co proces.""" + try: + async with self._app.router.lifespan_context(self._app): + self._gotowe.set() + await asyncio.Event().wait() # nigdy + except BaseException as exc: # noqa: BLE001 — re-raise przez zapewnij() + self._blad = exc + self._gotowe.set() + raise ``` -`LifespanMcp` woła `zapewnij()` na `lifespan.startup`; `RouterHttp` woła je -przed pierwszym żądaniem do `/mcp*`. Pod uvicornem zadziała pierwsze, pod -Daphne — drugie. **`session_manager.run()` można wejść raz na instancję** -(`streamable_http_manager.py:137-143`), więc blokada i double-check są -obowiązkowe, nie ozdobne. - -Kontekst zadania: `run()` zakłada grupę zadań anyio, która żyje tak długo, jak -kontekst, w którym ją utworzono. Przy starcie z żądania trzymamy `AsyncExitStack` -w obiekcie modułowym, więc nie ginie z żądaniem — **to jest miejsce wymagające -własnego testu**, nie założenia (§11). +`zapewnij()` musi być **pierwszą instrukcją** w `RouterHttp`, przed założeniem +jakiegokolwiek scope'u (§5.2). `LifespanMcp` woła je na `lifespan.startup`; +pod Daphne pierwsze wywołanie przychodzi z pierwszego żądania. Tworzenie +zadania jest jednorazowe (`self._zadanie is None`), a `Event` szereguje +równoległych czekających — blokada nie jest potrzebna, bo `create_task` +w jednej pętli nie ma punktu przerwania między testem a przypisaniem. **Błąd startu** raportujemy jako `lifespan.startup.failed` z komunikatem. Pod gunicornem oznacza to `should_exit` w uvicornie (`lifespan/on.py:58-60`) → worker pada → gunicorn respawnuje. Czyli **boot-loop, nie cisza** — i tak ma -być: awaria startu MCP nie może przejść niezauważona. +być. Pod Daphne błąd wraca z `zapewnij()` jako 503 na `/mcp`. + +**Zatrucie grupy mimo wszystko.** Wyjątek dziecka grupy poza `serve_connection` +anuluje scope grupy; pod uvicornem po starcie skutkuje to procesem, który żyje, +ale każde `/mcp` zwraca 500 aż do recyklingu `max_requests`. `StartMcp` +wystawia więc **stan gotowości** do healthchecku (§10.3), a `RouterHttp` przy +martwym zadaniu zwraca 503 zamiast 500. + +**`RouterHttp`** — dopasowanie **dokładne**, nie prefiksowe: + +| Ścieżka | Trafia do | +|---|---| +| `/mcp` | `BramkaBearera(tryb=publiczny)` → aplikacja MCP | +| `/mcp/auth` | `BramkaBearera(tryb=wymagany)` → **przepisanie ścieżki na `/mcp`** → aplikacja MCP | +| `/mcp/` | Django (strona HTML, §8) | +| reszta | Django | -**`RouterHttp`** rozdziela po **ścieżce**, nie po metodzie. `GET /mcp` -z `Accept: text/html` (człowiek wkleił adres do przeglądarki) przekierowujemy -na `/mcp/` — inaczej SDK odda 406 „Client must accept text/event-stream" -(`streamable_http.py:698-703`). Aplikacja MCP sama routuje na -`streamable_http_path`, więc **nie obcinamy prefiksu**. +**Przepisanie ścieżki jest obowiązkowe** (BL-1): SDK montuje trasę jako +`Route`, nie `Mount` (`lowlevel/server.py:808-813`), czyli regex `^/mcp$`. +Bez przepisania `/mcp/auth` dostałoby **404**, a `redirect_slashes` Starlette +próbowałoby jeszcze `/mcp/auth/`. Przepisujemy `scope["path"]` i `raw_path` +po przejściu przez bramkę. + +Prefiksowe `/mcp*` z wersji 3 było też sprzeczne z §8: `/mcp/` trafiłoby do +Starlette i dostało 307 na `/mcp`. + +`GET /mcp` z `Accept: text/html` (człowiek wkleił adres do przeglądarki) +przekierowujemy na `/mcp/` — inaczej SDK odda 406 +(`streamable_http.py:698-703`). ### 5.2 `KontekstMcp` i `KlientScope` — dwie warstwy, nie jedna @@ -318,12 +387,48 @@ zrobić. Reset ContextVarów na wyjściu obowiązkowy. -**Sufit czasu (D11).** Timeouty `httpx` są przy `ASGITransport` martwe, a -gunicornowy `timeout` to heartbeat. Jedno `tools/call` rozwija się na N żądań do -API bez żadnego limitu. `KontekstMcp` owija obsługę w `anyio.fail_after(...)` -i trzyma semafor ograniczający liczbę równoległych żądań wewnętrznych. Wartości -do ustalenia w planie, ale **istnienie limitu jest decyzją specu**, nie -„pomiarem na później". +**Sufit czasu (D11) — egzekwowany W KLIENCIE, nie wokół aplikacji.** + +Timeouty `httpx` są przy `ASGITransport` martwe, a gunicornowy `timeout` to +heartbeat. Jedno `tools/call` rozwija się na N żądań do API bez żadnego limitu. + +Wersja 3 kazała owinąć obsługę w `anyio.fail_after(...)` w `KontekstMcp`. +**To nie działa** (bloker BL-2 trzeciej recenzji). W trybie bezstanowym serwer +JSON-RPC startuje jako zadanie **grupy menedżera** +(`streamable_http_manager.py:243`), a handler narzędzia jest spawnowany do +grupy dispatchera (`jsonrpc_dispatcher.py:493,684`). `fail_after` wokół +`mcp_app(...)` anuluje wyłącznie zadanie żądania, które czeka na odpowiedź — +czyli: + +- **narzędzie i jego N żądań do Django biegną dalej**, tylko nikt już na nie + nie czeka; +- `http_transport.terminate()` **nie jest w `finally`** (`manager:246-249`), + więc `read_stream` nigdy się nie zamyka → `run_stateless_server` żyje + wiecznie → **wyciek zadania na każdy timeout**. + +Ten sam wyciek zachodzi przy **każdym rozłączeniu klienta** w trakcie +`tools/call`, nie tylko przy timeoucie. Przy `proxy_read_timeout 300s` to +realny wektor wyczerpania zasobów: N rozłączeń = N wiecznych zadań. + +Dlatego budżet trafia tam, gdzie faktycznie wykonuje się praca: + +- `KontekstMcp` kładzie **deadline** (monotoniczny znacznik) i **semafor** + w `ContextVar`; +- `BppClientInProcess` czyta deadline w `_request` i **sam podnosi `BppError`**, + gdy budżet wyczerpany, a każde pojedyncze wywołanie owija w `fail_after` + na resztkę budżetu; +- semafor ogranicza liczbę równoległych żądań wewnętrznych tego samego + wywołania narzędzia. + +Dzięki temu przekroczenie limitu **zatrzymuje pracę**, a nie tylko przestaje na +nią czekać — i wraca do klienta jako czytelny błąd narzędzia. + +Wartości do ustalenia w planie; **istnienie limitu jest decyzją specu**. + +**Wyciek przy rozłączeniu** jest niezależny od naszego budżetu i leży w SDK. +Do planu: zgłoszenie upstream oraz — do czasu naprawy — licznik żywych zadań +menedżera wystawiony w healthchecku (§10.3), żeby wyciek był widoczny, zanim +położy proces. ### 5.3 `BppClient` per żądanie (D9) @@ -380,9 +485,27 @@ wariantach montażu: | `/mcp` | przepuść jako anonim | **401** + `WWW-Authenticate` | przepuść z tożsamością | | `/mcp/auth` | **401** + `WWW-Authenticate` | **401** + `WWW-Authenticate` | przepuść z tożsamością | -Weryfikacja tokenu to lookup w tej samej bazie — wzorzec z -`ApiReadOnlyForBearerMiddleware._ma_wazny_bearer` (`middleware.py:31-40`), -opakowany w `sync_to_async` (kontekst async). +**Weryfikacja musi sprawdzać to samo, co DRF — nie mniej.** +`ApiReadOnlyForBearerMiddleware._ma_wazny_bearer` (`middleware.py:31-40`) +patrzy wyłącznie na `AccessToken.is_valid()`. `StrictOAuth2Authentication` +sprawdza dodatkowo `user.is_active` i scope `read` +(`authentication.py:33-35`). Gdyby `BramkaBearera` skopiowała tylko ten +pierwszy wzorzec, **token bez scope'u `read` przeszedłby bramkę i padł dopiero +w DRF** — czyli wróciłby jako błąd w treści JSON-RPC z HTTP 200, a klient nigdy +nie zrobiłby ponownej autoryzacji. Bramka musi więc sprawdzać: `is_valid()` +**oraz** `user.is_active` **oraz** scope `read`. + +To jest **trzecia kopia** tych reguł w repo (obok `authentication.py` +i `middleware.py`). Wersja 2 specu proponowała konsolidację w +`oauth_mcp/tokens.py` i słusznie odsunąłem ją do projektu 3 jako zmianę +w testowanej warstwie auth — ale trzecia kopia jest ceną, którą trzeba nazwać. +**Do planu: minimalny wspólny helper czytany przez wszystkie trzy miejsca**, +bez ruszania struktury klas DRF. Pełna konsolidacja zostaje projektem 3. + +Lookup idzie przez `sync_to_async` (kontekst async). Ponieważ dzieje się **poza +cyklem żądania Django** (brak sygnałów `request_started`/`request_finished`), +obowiązkowe jest `close_old_connections()` wokół zapytania — inaczej połączenia +do bazy nie są sprzątane. **Dlaczego dwa adresy.** `/mcp` nigdy nie zmusi klienta do logowania, bo anonim dostaje 200 — a czy klienci MCP odpalają OAuth przy 401 na **późniejszym** @@ -423,8 +546,10 @@ hostów. Trzy rzeczy, których wersja 2 nie widziała: (`transport_security.py:49-60`). Django-owe `.domena` i `*` **nie działają**; trzeba je przetłumaczyć, a testy repo używają `ALLOWED_HOSTS = ["*"]`. 2. **Sprawdzany jest też `Origin`** (`:65-70`) — klienci przeglądarkowe - i MCP Inspector go wysyłają; pusta lista `allowed_origins` + obecny `Origin` - → 421. + i MCP Inspector go wysyłają. Niezgodny `Origin` daje **403** + (`transport_security.py:113-114`), nie 421. Decyzja: `allowed_origins` + wypełniamy tymi samymi hostami co `allowed_hosts`, ze schematem `https://` + (plus `http://` dla `localhost` w dev). 3. **`host=` bez `transport_security` po cichu wyłącza ochronę** (`lowlevel/server.py:738-744`). Dlatego **zawsze** przekazujemy jawne `TransportSecuritySettings`. @@ -450,6 +575,16 @@ to `Bearer`**, plus `Accept` i `Content-Type`. Odcinamy `Cookie`, prefiks `bearer `** (`middleware.py:33`) — więc dla Basica warstwa read-only **nie istnieje**. +**Gdzie ta allowlista musi stać.** SDK przekazuje narzędziu **cały obiekt +`Request` zewnętrznego żądania** przez `SessionMessage.metadata.request` +(`streamable_http.py:608-611`) — z `Cookie` włącznie. Dziś czyta z niego tylko +`bearer_from_request` (`bpp_mcp/auth.py:46-53`), więc D6 jest w praktyce +zachowane, ale **przez dyscyplinę kodu w cudzym pakiecie, nie przez izolację**. +Nasza allowlista działa przy budowaniu żądania wewnętrznego +(`BppClientInProcess`), i to ona jest właściwą zaporą. Test „`Cookie` nie +uwierzytelnia" **musi być po stronie BPP**, nie tylko w `bpp-mcp` — bo to my +ponosimy skutki, gdyby tamta dyscyplina się zmieniła. + ### 7.2 Propagacja `Host` — to jest kontrola dostępu Wewnętrzne żądanie **musi** nieść `Host` zewnętrznego. Inaczej `Uczelnia=None` @@ -484,7 +619,14 @@ w `KontekstMcp` (ścieżka, kod, czas, obecność bearera — **nigdy token**). z `LifespanMcp`, `KontekstMcp` ani aplikacji MCP — te nie trafią do Rollbara same. Warstwa MCP woła `rollbar.report_exc_info()` jawnie i **nie wkłada nagłówków do `extra_data`**. (`"authorization"` jest w `ROLLBAR_SCRUB_FIELDS`, -`base.py:1777`, ale to dotyczy żądań Django — zamyka §15.7.) +`base.py:1777`, ale to dotyczy żądań Django.) + +**Wyjątki narzędzi wymagają osobnego przechwycenia.** SDK zamienia każdy +wyjątek handlera na `CallToolResult(is_error=True)` (`mcpserver/server.py:423-424`), +więc **nic nie dociera** do warstwy ASGI — jawne `report_exc_info()` tam by ich +nie zobaczyło. Potrzebny jest cienki wrapper wokół `register_tools`, który +owija każde zarejestrowane narzędzie i raportuje wyjątek, zanim SDK go zamieni +na wynik. Bez tego awarie narzędzi są **całkowicie niewidoczne** w monitoringu. ### 7.6 Przekierowania — `follow_redirects=False` (D12) @@ -574,6 +716,13 @@ bo na niej stoi D4. Poza zakresem tego specu (§13). z D11 działa **wewnątrz** aplikacji; strefa nginx chroni przed zalewem samych żądań MCP. Do zmierzenia, ale nie jest to już „poza zakresem" (§7.3). +3. **Healthcheck `/mcp`.** Grupa zadań menedżera może zostać „zatruta" + (wyjątek dziecka anuluje scope grupy) — proces wtedy żyje, ale **każde + `/mcp` zwraca błąd aż do recyklingu `max_requests`**. `StartMcp` wystawia + stan gotowości i licznik żywych zadań; `RouterHttp` przy martwym zadaniu + oddaje 503 zamiast 500, a monitoring ma po czym poznać, że endpoint padł + mimo zdrowego procesu (§5.1, §5.2). + Bez zmian: `stateless_http` czyni recykling `--max-requests` nieszkodliwym, `json_response` znosi problem `proxy_read_timeout`. @@ -599,7 +748,15 @@ zobaczy override'u z fixture'a `settings` (§6.1); (ii) próbuje startować ponownie - ruch spoza `/mcp` do Django; `websocket` do channels - `GET /mcp` z `Accept: text/html` → redirect na `/mcp/` -- `GET /mcp` bez tego nagłówka przy `stateless_http` → **405** (nie SSE) +- **`/mcp/auth` dociera do aplikacji MCP** — dowód, że przepisanie ścieżki + działa (bez niego SDK dałoby 404, BL-1) +- `/mcp/` (ze slashem) trafia do **Django**, nie do Starlette +- start menedżera odbywa się w **osobnym zadaniu**: `zapewnij()` wywołane + z zadania, w którym jest aktywny `fail_after`, **nie** psuje ani tego + żądania, ani kolejnych (regresja na BL-3) +- po przekroczeniu budżetu czasu **narzędzie przestaje wykonywać żądania** + do Django (licznik wywołań się nie zwiększa) — a nie tylko przestajemy + czekać (regresja na BL-2) **Bezpieczeństwo (każdy osobno)** - `Cookie` wysłany do `/mcp` **nie** uwierzytelnia — także po wewnętrznym @@ -645,7 +802,11 @@ zobaczy override'u z fixture'a `settings` (§6.1); (ii) - `/.well-known/oauth-protected-resource` poprawne przy wielu hostach. - `GET /mcp/` renderuje stronę z oboma adresami właściwymi dla hosta. - Narzędzia z `bpp_mcp` — **żadnej kopii** w `src/`. -- Jawny sufit czasu i współbieżności działa (D11). +- Jawny sufit czasu **zatrzymuje pracę narzędzia**, nie tylko odpowiedź (D11). +- `/mcp/auth` dociera do aplikacji MCP (przepisanie ścieżki, BL-1). +- Menedżer sesji startuje w osobnym zadaniu i przeżywa żądanie, w którym go + wywołano — także gdy w tym żądaniu był aktywny `fail_after` (BL-3). +- Healthcheck rozróżnia stan procesu od stanu endpointu MCP (§10.3). - Testy zielone; `ruff` czysty; `pre-commit` bez uwag. - Newsfragment w `src/bpp/newsfragments/`. - Istniejące testy `oauth_mcp` i `api_v1` przechodzą **bez modyfikacji**. @@ -670,55 +831,60 @@ Strefa `limit_req` **przestaje** być poza zakresem — patrz §10.2. ## 14. Rozliczenie z recenzjami -**Wersja 1 → 2** (pierwsza recenzja): bloker lifespanu potwierdzony, ale -rozwiązywalny; bloker hosta potwierdzony i groźniejszy (kontrola dostępu, nie -błąd 400); dodane `Basic`, cache, throttling, ModSecurity, allowlista DCR; -poprawione 7 → 11 narzędzi; zakres rozbity na trzy projekty. +**Wersja 1 → 2:** bloker lifespanu potwierdzony, ale rozwiązywalny; bloker +hosta potwierdzony i groźniejszy (kontrola dostępu, nie błąd 400); dodane +`Basic`, cache, throttling, ModSecurity, allowlista DCR; 7 → 11 narzędzi; +zakres rozbity na trzy projekty. + +**Wersja 2 → 3:** B1 (`base_url` per żądanie) → D9; B2 (Daphne) → leniwy start; +B3 (401 + PRM) → D10 dwa adresy; plus dwanaście poprawek. -**Wersja 2 → 3** (druga recenzja): +**Wersja 3 → 4.** Trzecia recenzja **potwierdziła D9 jako zamknięte** i wskazała +trzy nowe blokery — wszystkie w rozwiązaniach B2/B3, nie w architekturze: -| Ustalenie | Rozstrzygnięcie | +| Ustalenie | Rozstrzygnięcie w tej wersji | |---|---| -| **B1** `base_url` per żądanie niewykonalne z 0.4.0 | **D9** — klient per żądanie przez property w obiekcie lifespanu; zero zmian w `bpp-mcp` (§5.3) | -| **B2** lifespan nie działa pod Daphne (dev, Playwright) | **D2** — leniwy start idempotentny, wyzwalany z obu stron (§5.1) | -| **B3** brak wykonawcy 401 + PRM; `token_verifier` kasuje anonima | **D10** — własna `BramkaBearera` i **dwa adresy** (§5.4) | -| IP klienta nieosiągalne przed aplikacją MCP | `KlientScope` za transportem (§5.2) | -| scheme i `SECURE_SSL_REDIRECT` | propagacja + `follow_redirects=False` (§7.6) | -| semantyka allowlisty SDK, `Origin`, `host=` bez `transport_security` | §6.1 | -| `FirstRunWizardMiddleware` przekierowuje | potwierdzone, **zamyka §15.6** (§7.6) | -| brak sufitu czasu | **D11** (§5.2) | -| `GET /mcp` → 406 | redirect przy `Accept: text/html` (§5.1) | -| Rollbar nie obejmuje warstwy MCP | jawne `report_exc_info` (§7.5), **zamyka §15.7** | -| throttling po całym XFF | nie dokładamy XFF (§7.3) | -| `startup.failed` → boot-loop | nazwane jako oczekiwane (§5.1) | -| testy: fabryka, brak `pytest-asyncio`, `run()` raz | §11 | -| `stateless_http` + GET → 405, nie SSE | §11 | -| numery linii `views_dcr`, `channels/routing` | poprawione (§2.1, §2.4) | -| `CountdownBlockingMiddleware` nie zwalnia `/api/` | §7.7 | - -**Nieprawdziwe twierdzenia wersji 2, wycofane:** „szew cache rozwiązuje problem -współdzielenia" (rozwiązywał połowę — `_api_root` został, D9 rozwiązuje oba); -„throttling potwierdzony" (problem tak, mechanizm naprawy nie istniał); -„lifespan rozwiązany jednym kluczem" (tylko pod uvicornem). +| **BL-1** `/mcp/auth` → 404; SDK montuje `Route` (`^/mcp$`), nie `Mount` | `RouterHttp` **przepisuje ścieżkę** na `/mcp` po bramce; dopasowanie dokładne, nie prefiksowe (§5.1) | +| **BL-2** `fail_after` wokół aplikacji nie zatrzymuje narzędzia i wycieka zadanie | budżet i semafor **w `BppClientInProcess`**, egzekwowane w `_request` (§5.2) | +| **BL-3** leniwy start pęka pod własnym `fail_after`; anulowanie hosta zatruwa grupę | start w **osobnym, długowiecznym zadaniu**; `zapewnij()` przed jakimkolwiek scope'em (§5.1) | +| bramka sprawdzała mniej niż DRF (brak scope, `is_active`) | pełny zestaw reguł + `close_old_connections` (§5.4) | +| `KlientScope` bez decyzji o pustym ContextVar | fail-closed (§5.2) | +| `Origin` → 403, nie 421; brak reguły dla `allowed_origins` | §6.1 | +| Rollbar nie zobaczy wyjątków narzędzi | wrapper wokół `register_tools` (§7.5) | +| `Cookie` dociera do narzędzia przez `SessionMessage` | nazwane; test po stronie BPP (§7.1) | +| wyciek zadania przy **każdym** rozłączeniu klienta | nazwane, healthcheck + zgłoszenie upstream (§5.2, §10.3) | +| zatrucie grupy także pod uvicornem | healthcheck, 503 zamiast 500 (§10.3) | +| `Uczelnia=None` przesadzone dla single-site | sprostowane — dotyczy wielouczelnianości (§2.5) | +| `stateless + GET → 405` niepotwierdzone | wycofane z §2.3, test warunkowy (§11) | +| dwie ścieżki protokołu, różny czas życia | §2.3, uwzględnione w §5.2 | + +**Wycofane twierdzenia wersji 3:** „B2 zamknięty przez D2" i „B3 zamknięty przez +D10" — obie były przedwczesne; zamykają je dopiero poprawki tej wersji. +„Rollbar zamyka §15.7" — nie obejmowało wyjątków narzędzi. --- ## 15. Otwarte kwestie -Zamknięte w tej wersji: **§15.1** (kształt endpointu → D10), **§15.2** -(`starlette` — brak kolizji, §9), **§15.6** (kreator przekierowuje — tak, §7.6), -**§15.7** (Rollbar — §7.5). +Zamknięte: **§15.1 wersji 2** (kształt endpointu → D10); **`starlette`** (brak +kolizji, §9); **kreator przekierowuje** (tak, §7.6); **Rollbar** (§7.5); +**`anyio`** — `mcp 2.0` wymaga `>=4.9`, BPP ma 4.11.0 (§9). Zostają: -1. Czy `mcp 2.0` wymaga `anyio` nowszego niż 4.11.0 z `uv.lock` (§9). -2. Wpływ dziesięciu nowych pakietów na bramkę Trivy i rozmiar obrazu (§9). -3. Wartości sufitu czasu i współbieżności (D11) — do ustalenia pomiarem, ale - **istnienie limitu jest już zdecydowane**. -4. Kształt reguły wyłączającej ModSecurity dla `/mcp` (§10.1). -5. Czy strefa `limit_req` dla `/mcp` jest potrzebna (§10.2). -6. Czy `httpx2` ma `ASGITransport` o semantyce wymaganej przez D4 — warunek - wstępny dla przejścia `bpp-mcp` na jeden klient HTTP (§9, §13). -7. **Trwałość grupy zadań anyio przy leniwym starcie** — `run()` wchodzony - z kontekstu żądania, a `AsyncExitStack` trzymany w obiekcie modułowym. - Do potwierdzenia testem pod Daphne (§5.1, §11). +1. Wpływ dziesięciu nowych pakietów na bramkę Trivy i rozmiar obrazu (§9). +2. Wartości budżetu czasu i semafora (D11) — pomiar; **istnienie limitu jest + zdecydowane** (§5.2). +3. Kształt reguły wyłączającej ModSecurity dla `/mcp` (§10.1). +4. Czy strefa `limit_req` dla `/mcp` jest potrzebna (§10.2). +5. Czy `httpx2` ma `ASGITransport` o semantyce wymaganej przez D4 — warunek + przejścia `bpp-mcp` na jeden klient HTTP (§9, §13). +6. Czy `stateless_http` + `GET` daje 405 — do sprawdzenia przed napisaniem + testu (§2.3, §11). +7. Czy klienci Claude walidują pole `resource` w PRM względem adresu serwera — + przy dwóch adresach (`/mcp`, `/mcp/auth`) może wymagać metadanych pod + `/.well-known/oauth-protected-resource/<ścieżka>` (RFC 9728 §3.1). **Do + sprawdzenia ręcznie na obu adresach.** +8. Zgłoszenie upstream braku `terminate()` w `finally` + (`streamable_http_manager.py:246-249`) — wyciek zadania przy rozłączeniu + klienta (§5.2). From c542ada145aa972c34e76ef5e4f3e1d635cf1458 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sun, 6 Sep 2026 14:10:39 +0200 Subject: [PATCH 06/29] =?UTF-8?q?docs(plan):=20plan=20wdro=C5=BCenia=20hos?= =?UTF-8?q?towanego=20MCP=20=E2=80=94=2012=20zada=C5=84=20TDD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU --- .../2026-09-06-mcp-hostowany-w-serwisie.md | 2110 +++++++++++++++++ 1 file changed, 2110 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-06-mcp-hostowany-w-serwisie.md diff --git a/docs/superpowers/plans/2026-09-06-mcp-hostowany-w-serwisie.md b/docs/superpowers/plans/2026-09-06-mcp-hostowany-w-serwisie.md new file mode 100644 index 000000000..44318053e --- /dev/null +++ b/docs/superpowers/plans/2026-09-06-mcp-hostowany-w-serwisie.md @@ -0,0 +1,2110 @@ +# Hostowany serwer MCP w serwisie BPP — plan wdrożenia + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps use +> checkbox (`- [ ]`) syntax for tracking. + +**Goal:** BPP wystawia własny endpoint MCP pod `/mcp` (publiczny) i `/mcp/auth` +(wymagający OAuth), wykonując narzędzia z pakietu `bpp-mcp` i pobierając dane +wywołaniem własnej aplikacji Django w procesie. + +**Architecture:** Do istniejącego `ProtocolTypeRouter` w `django_bpp/asgi.py` +dochodzi klucz `"lifespan"` oraz router HTTP rozdzielający `/mcp` od reszty. +Menedżer sesji MCP startuje w osobnym, długowiecznym zadaniu (bo Daphne nie +implementuje lifespanu). Każde żądanie dostaje własnego `BppClient` +skonfigurowanego na host tego żądania, wołającego `django_asgi_app` przez +`httpx.ASGITransport`. + +**Tech Stack:** Django 5.2, channels 4.3, DRF 3.18, `mcp>=2,<3` +(przez `bpp-mcp>=0.4,<0.5`), `httpx`, `anyio`, pytest. + +**Spec:** `docs/superpowers/specs/2026-09-05-mcp-hostowany-w-serwisie-design.md` + +## Global Constraints + +- Wszystkie komendy przez `uv run` (`uv run pytest`, `uv run ruff …`). +- **Max długość linii: 88 znaków** (ruff). +- Komentarze i docstringi **po polsku**, w stylu repo (uzasadnienie „dlaczego"). +- **NIGDY** gołe `except:` ani `except Exception: pass`. +- Testy: pytest, funkcje bez klas, `@pytest.mark.django_db`, `model_bakery.baker`. +- **Repo nie ma `pytest-asyncio`.** Korutyny odpala się przez `asyncio.run` + w osobnym wątku (wzorzec: `src/django_bpp/tests/test_asgi_notifications.py`). +- **Nie modyfikować istniejących migracji.** +- Istniejące testy `oauth_mcp` i `api_v1` muszą przechodzić **bez zmian**. +- Nie uruchamiać `ruff check --fix` ani innych masowych auto-poprawek. +- Aplikacja MCP budowana **fabryką** `build_application()`, nigdy przy imporcie + modułu — inaczej `settings` nadpisane w teście do niej nie dotrą. +- `ASGITransport` dostaje **`django_asgi_app`**, nigdy `application` (rekurencja). + +--- + +## Struktura plików + +``` +src/mcp_server/ + __init__.py + apps.py # McpServerConfig + kontekst.py # ContextVary + KontekstZadania + KontekstMcp + klient.py # BppClientInProcess + KlientScope + auth.py # BramkaBearera + routing.py # RouterHttp + LifespanMcp + start.py # StartMcp + aplikacja.py # build_application() + zdrowie.py # stan dla healthchecku + views.py # strona /mcp/ dla człowieka + urls.py + templates/mcp_server/index.html + tests/ + __init__.py + utils.py # uruchom(), zbuduj_scope(), wywolaj() + test_start.py + test_routing.py + test_klient.py + test_auth.py + test_aplikacja.py + test_bezpieczenstwo.py + test_wielohost.py +src/oauth_mcp/views_metadata.py # + oauth_protected_resource_metadata +src/oauth_mcp/urls.py # + trasa PRM +src/django_bpp/asgi.py # montaż +src/django_bpp/settings/base.py # INSTALLED_APPS +pyproject.toml # bpp-mcp +``` + +--- + +## Task 1: Zależność `bpp-mcp` i szkielet aplikacji + +**Files:** +- Modify: `pyproject.toml` +- Modify: `src/django_bpp/settings/base.py` (INSTALLED_APPS) +- Create: `src/mcp_server/__init__.py`, `src/mcp_server/apps.py` +- Create: `src/mcp_server/tests/__init__.py` +- Test: `src/mcp_server/tests/test_aplikacja.py` + +**Interfaces:** +- Produces: aplikacja Django `mcp_server`; dostępny import `bpp_mcp`. + +- [ ] **Step 1: Write the failing test** + +```python +# src/mcp_server/tests/test_aplikacja.py +"""Aplikacja mcp_server jest zarejestrowana, a pakiet bpp-mcp dostępny.""" + +from django.apps import apps + + +def test_aplikacja_zarejestrowana(): + assert apps.is_installed("mcp_server") + + +def test_pakiet_bpp_mcp_dostepny(): + from bpp_mcp import register_tools + + assert callable(register_tools) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest src/mcp_server/tests/test_aplikacja.py -v` +Expected: FAIL — brak modułu `mcp_server`. + +- [ ] **Step 3: Dodaj zależność** + +W `pyproject.toml`, w sekcji `dependencies`, po linii z `django-multiseek`: + +```toml + # Narzędzia MCP + szwy do hostowania ich w naszym procesie (spec §2.2). + # Ciągnie mcp>=2,<3, starlette i httpx — patrz spec §9. + "bpp-mcp>=0.4,<0.5", +``` + +Następnie: `uv lock && uv sync` + +- [ ] **Step 4: Utwórz aplikację** + +```python +# src/mcp_server/__init__.py +"""Hostowany endpoint MCP serwisu BPP (/mcp, /mcp/auth). + +Narzędzia pochodzą z pakietu ``bpp-mcp``; ta aplikacja dostarcza wyłącznie +warstwę montażu: routing ASGI, kontekst żądania, uwierzytelnianie i klienta +wołającego własną aplikację Django w procesie. +""" +``` + +```python +# src/mcp_server/apps.py +from django.apps import AppConfig + + +class McpServerConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "mcp_server" + verbose_name = "Serwer MCP" +``` + +```python +# src/mcp_server/tests/__init__.py +``` + +- [ ] **Step 5: Zarejestruj w INSTALLED_APPS** + +W `src/django_bpp/settings/base.py`, w `INSTALLED_APPS`, bezpośrednio po +`"oauth2_provider",`: + +```python + "mcp_server", +``` + +- [ ] **Step 6: Run test to verify it passes** + +Run: `uv run pytest src/mcp_server/tests/test_aplikacja.py -v` +Expected: PASS (2 passed) + +- [ ] **Step 7: Commit** + +```bash +git add pyproject.toml uv.lock src/django_bpp/settings/base.py src/mcp_server +git commit -m "feat(mcp_server): szkielet aplikacji + zależność bpp-mcp" +``` + +--- + +## Task 2: `StartMcp` — start menedżera sesji w osobnym zadaniu + +Menedżer sesji MCP musi wejść w `session_manager.run()` dokładnie raz. Wejście +z zadania **żądania** czyni je host taskiem grupy zadań anyio — a wtedy aktywny +w tym zadaniu cancel scope (np. nasz budżet czasu) rzuca `RuntimeError` przy +wyjściu, i grupa zostaje trwale zepsuta (spec §5.1, bloker BL-3). + +**Files:** +- Create: `src/mcp_server/start.py` +- Create: `src/mcp_server/tests/utils.py` +- Test: `src/mcp_server/tests/test_start.py` + +**Interfaces:** +- Produces: `StartMcp(app)` z `await zapewnij()`, `wystartowany: bool`, + `zywy: bool`. +- Produces: `mcp_server.tests.utils.uruchom(coro)` — odpala korutynę w osobnym + wątku (repo nie ma `pytest-asyncio`). + +- [ ] **Step 1: Napisz pomocnik testowy** + +```python +# src/mcp_server/tests/utils.py +"""Pomocniki testowe warstwy ASGI. + +Repo nie ma ``pytest-asyncio``, a ``async_to_sync`` rzuca, gdy w bieżącym +wątku biegnie już pętla (flaky zależny od kolejności testów — patrz +``django_bpp/tests/test_asgi_notifications.py``). Odpalamy więc każdą korutynę +przez ``asyncio.run`` w dedykowanym wątku ze świeżą pętlą. +""" + +import asyncio +import json +from concurrent.futures import ThreadPoolExecutor + + +def uruchom(coro_factory): + """Odpal fabrykę korutyn w osobnym wątku i zwróć jej wynik. + + Przyjmujemy FABRYKĘ, nie korutynę: korutyna utworzona w wątku wołającym + byłaby związana z jego (nieistniejącą) pętlą. + """ + with ThreadPoolExecutor(max_workers=1) as ex: + return ex.submit(lambda: asyncio.run(coro_factory())).result() + + +def zbuduj_scope(sciezka, metoda="POST", host="bpp.example.test", naglowki=None): + """Minimalny scope HTTP/ASGI do wołania aplikacji wprost.""" + bazowe = [(b"host", host.encode())] + for k, v in (naglowki or {}).items(): + bazowe.append((k.lower().encode(), v.encode())) + return { + "type": "http", + "asgi": {"version": "3.0", "spec_version": "2.3"}, + "http_version": "1.1", + "method": metoda, + "scheme": "http", + "path": sciezka, + "raw_path": sciezka.encode(), + "query_string": b"", + "root_path": "", + "headers": bazowe, + "client": ("203.0.113.7", 54321), + "server": (host, 80), + } + + +async def wywolaj(app, scope, cialo=None): + """Zawołaj aplikację ASGI i zwróć ``(status, naglowki, tekst)``.""" + raw = json.dumps(cialo).encode() if cialo is not None else b"" + do_odebrania = [{"type": "http.request", "body": raw, "more_body": False}] + wyslane = [] + + async def receive(): + return do_odebrania.pop(0) if do_odebrania else {"type": "http.disconnect"} + + async def send(msg): + wyslane.append(msg) + + await app(scope, receive, send) + start = next((m for m in wyslane if m["type"] == "http.response.start"), None) + tresc = b"".join( + m.get("body", b"") for m in wyslane if m["type"] == "http.response.body" + ) + naglowki = {k.decode(): v.decode() for k, v in (start or {}).get("headers", [])} + return (start or {}).get("status"), naglowki, tresc.decode(errors="replace") +``` + +- [ ] **Step 2: Write the failing test** + +```python +# src/mcp_server/tests/test_start.py +"""StartMcp: wejście w lifespan aplikacji MCP dokładnie raz, w osobnym zadaniu. + +Kluczowa regresja (spec §5.1, bloker BL-3): wejście w grupę zadań anyio +z zadania ŻĄDANIA czyni je host taskiem; aktywny w tym zadaniu cancel scope +rzuca wtedy RuntimeError przy wyjściu, a grupa zostaje trwale zepsuta. +""" + +import asyncio +from contextlib import asynccontextmanager + +import anyio +import pytest + +from mcp_server.start import StartMcp +from mcp_server.tests.utils import uruchom + + +class _AplikacjaZLifespanem: + """Namiastka aplikacji Starlette: liczy wejścia w lifespan.""" + + def __init__(self, blad=None): + self.wejscia = 0 + self._blad = blad + self.router = self + + @asynccontextmanager + async def lifespan_context(self, _app): + if self._blad is not None: + raise self._blad + self.wejscia += 1 + yield + + +def test_zapewnij_wchodzi_dokladnie_raz(): + app = _AplikacjaZLifespanem() + start = StartMcp(app) + + async def scenariusz(): + await start.zapewnij() + await start.zapewnij() + await start.zapewnij() + return start.wystartowany + + assert uruchom(scenariusz) is True + assert app.wejscia == 1 + + +def test_zapewnij_pod_aktywnym_cancel_scope_nie_psuje_zadania(): + """REGRESJA BL-3: wywołanie z zadania, w którym trwa fail_after, nie może + ani wywalić tego zadania, ani zepsuć kolejnych wywołań.""" + app = _AplikacjaZLifespanem() + start = StartMcp(app) + + async def scenariusz(): + with anyio.fail_after(5): + await start.zapewnij() + # wyjście z fail_after musi się udać — i drugie wywołanie też + await start.zapewnij() + return app.wejscia + + assert uruchom(scenariusz) == 1 + + +def test_blad_startu_jest_podnoszony_i_zapamietany(): + app = _AplikacjaZLifespanem(blad=RuntimeError("brak Redisa")) + start = StartMcp(app) + + async def scenariusz(): + with pytest.raises(RuntimeError): + await start.zapewnij() + return start.zywy + + assert uruchom(scenariusz) is False + + +def test_zadanie_przezywa_zadanie_ktore_je_utworzylo(): + """Grupa zadań ma żyć dłużej niż żądanie, z którego wystartowała.""" + app = _AplikacjaZLifespanem() + start = StartMcp(app) + + async def scenariusz(): + async def udaje_zadanie(): + await start.zapewnij() + + await asyncio.create_task(udaje_zadanie()) + await asyncio.sleep(0.05) + return start.zywy + + assert uruchom(scenariusz) is True +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `uv run pytest src/mcp_server/tests/test_start.py -v` +Expected: FAIL — `ModuleNotFoundError: mcp_server.start` + +- [ ] **Step 4: Write implementation** + +```python +# src/mcp_server/start.py +"""Jednorazowe wejście w lifespan aplikacji MCP, w osobnym zadaniu. + +Menedżer sesji MCP zakłada grupę zadań anyio (``session_manager.run()``). +Zadanie, które w nią wchodzi, staje się jej HOST TASKIEM — a to ma dwie +konsekwencje, przez które nie wolno robić tego z zadania obsługującego +żądanie (spec §5.1): + +* jeżeli w tym zadaniu jest aktywny inny cancel scope (nasz budżet czasu), + wyjście z niego rzuci ``RuntimeError: Attempted to exit a cancel scope that + isn't the current task's current cancel scope`` — a grupa jest już wtedy + oznaczona jako wystartowana, więc padają też WSZYSTKIE kolejne żądania; +* anulowanie host taska (timeout, ``application_close_timeout`` Daphne) + zatruwa grupę nieodwracalnie, bo ``run()`` wchodzi się raz na instancję. + +Dlatego host taskiem jest zadanie tła, żyjące tyle, co proces. +""" + +from __future__ import annotations + +import asyncio + + +class StartMcp: + """Trzyma lifespan aplikacji MCP przy życiu przez cały czas życia procesu.""" + + def __init__(self, aplikacja) -> None: + self._aplikacja = aplikacja + self._gotowe = asyncio.Event() + self._zadanie: asyncio.Task | None = None + self._blad: BaseException | None = None + + @property + def wystartowany(self) -> bool: + """Czy lifespan został pomyślnie otwarty.""" + return self._gotowe.is_set() and self._blad is None + + @property + def zywy(self) -> bool: + """Czy zadanie trzymające grupę nadal działa (healthcheck, spec §10.3).""" + if self._blad is not None: + return False + return self._zadanie is not None and not self._zadanie.done() + + async def zapewnij(self) -> None: + """Wystartuj menedżera sesji, jeśli jeszcze nie działa, i poczekaj. + + Idempotentne. Wołane z ``LifespanMcp`` (uvicorn) albo z pierwszego + żądania (Daphne — nie implementuje protokołu lifespan). + """ + if self._zadanie is None: + # create_task nie ma punktu przerwania między testem a przypisaniem + # w tej samej pętli, więc blokada nie jest potrzebna. + self._zadanie = asyncio.get_running_loop().create_task(self._trzymaj()) + await self._gotowe.wait() + if self._blad is not None: + raise RuntimeError("Menedżer sesji MCP nie wstał") from self._blad + + async def _trzymaj(self) -> None: + """Host task grupy zadań — czeka w nieskończoność.""" + try: + async with self._aplikacja.router.lifespan_context(self._aplikacja): + self._gotowe.set() + await asyncio.Event().wait() + except BaseException as exc: + self._blad = exc + self._gotowe.set() + raise +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `uv run pytest src/mcp_server/tests/test_start.py -v` +Expected: PASS (4 passed) + +- [ ] **Step 6: Commit** + +```bash +git add src/mcp_server/start.py src/mcp_server/tests/ +git commit -m "feat(mcp_server): StartMcp — lifespan MCP w osobnym zadaniu" +``` + +--- + +## Task 3: Kontekst żądania i `KlientScope` + +**Files:** +- Create: `src/mcp_server/kontekst.py` +- Create: `src/mcp_server/klient.py` (część: `KlientScope`) +- Test: `src/mcp_server/tests/test_klient.py` + +**Interfaces:** +- Produces: `kontekst.dane_zadania` (ContextVar `DaneZadania | None`), + `kontekst.DaneZadania(host, scheme, ip, bearer, deadline)`. +- Produces: `klient.KlientScope(app)` — ASGI wrapper nadpisujący + `scope["client"]`. + +- [ ] **Step 1: Write the failing test** + +```python +# src/mcp_server/tests/test_klient.py +"""KlientScope: wewnętrzne żądanie musi nieść PRAWDZIWE IP pytającego. + +httpx.ASGITransport buduje scope sam i wpisuje tam własne ``client`` +(127.0.0.1). Bez podmiany wszyscy anonimowi użytkownicy MCP dzieliliby jeden +kubełek throttlingu DRF — jeden klient DoS-owałby wszystkich (spec §7.3). +""" + +import pytest + +from mcp_server.kontekst import DaneZadania, dane_zadania +from mcp_server.klient import KlientScope +from mcp_server.tests.utils import uruchom + + +async def _echo_client(scope, receive, send): + await send( + {"type": "http.response.start", "status": 200, "headers": []} + ) + await send({"type": "http.response.body", "body": str(scope["client"]).encode()}) + + +def test_klient_scope_wstawia_ip_z_kontekstu(): + app = KlientScope(_echo_client) + + async def scenariusz(): + zeton = dane_zadania.set( + DaneZadania( + host="bpp.example.test", + scheme="https", + ip="198.51.100.9", + bearer=None, + deadline=None, + ) + ) + try: + wyslane = [] + + async def send(m): + wyslane.append(m) + + async def receive(): + return {"type": "http.request", "body": b"", "more_body": False} + + await app({"type": "http", "client": ("127.0.0.1", 123)}, receive, send) + return b"".join(m.get("body", b"") for m in wyslane).decode() + finally: + dane_zadania.reset(zeton) + + assert "198.51.100.9" in uruchom(scenariusz) + + +def test_klient_scope_bez_kontekstu_rzuca(): + """FAIL-CLOSED: brak kontekstu to błąd programisty, nie powód do cichego + fallbacku na 127.0.0.1 (spec §5.2).""" + app = KlientScope(_echo_client) + + async def scenariusz(): + with pytest.raises(RuntimeError): + await app({"type": "http", "client": ("127.0.0.1", 1)}, None, None) + + uruchom(scenariusz) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest src/mcp_server/tests/test_klient.py -v` +Expected: FAIL — `ModuleNotFoundError: mcp_server.kontekst` + +- [ ] **Step 3: Write implementation** + +```python +# src/mcp_server/kontekst.py +"""Dane zewnętrznego żądania, przenoszone do warstwy wykonującej narzędzia. + +Klient MCP żyje w lifespanie (jeden na proces), a host, protokół, IP i token +zależą od KONKRETNEGO żądania. Przenosimy je ContextVarem — MCP SDK celowo +kopiuje kontekst przez granicę zadań (transport zapisuje ``copy_context()`` +nadawcy, dispatcher odtwarza go przy uruchamianiu handlera), więc wartość +ustawiona w warstwie ASGI jest widoczna w kodzie narzędzia. +""" + +from __future__ import annotations + +from contextvars import ContextVar +from dataclasses import dataclass + + +@dataclass(frozen=True) +class DaneZadania: + """Wszystko, czego wewnętrzne żądanie nie ma skąd wziąć samo.""" + + host: str + scheme: str + ip: str + bearer: str | None + deadline: float | None # monotoniczny znacznik końca budżetu + + +dane_zadania: ContextVar[DaneZadania | None] = ContextVar( + "mcp_dane_zadania", default=None +) + + +def biezace() -> DaneZadania: + """Zwróć dane bieżącego żądania albo rzuć — NIGDY nie zgaduj. + + Fallback na wartości domyślne oznaczałby ciche żądanie z nieznanego hosta, + czyli obejście bramki API i wyciek między uczelniami (spec §7.2). + """ + dane = dane_zadania.get() + if dane is None: + raise RuntimeError( + "Brak kontekstu żądania MCP — warstwa KontekstMcp nie została " + "uruchomiona albo ContextVar został zresetowany za wcześnie." + ) + return dane +``` + +```python +# src/mcp_server/klient.py +"""Klient wołający własną aplikację Django w procesie.""" + +from __future__ import annotations + +from mcp_server.kontekst import biezace + + +class KlientScope: + """Nadpisuje ``scope["client"]`` prawdziwym IP pytającego. + + ``httpx.ASGITransport`` buduje scope wewnętrznego żądania sam i wpisuje + tam ``("127.0.0.1", 123)``. Warstwa stojąca PRZED aplikacją MCP nie ma jak + tego dosięgnąć — to jedyne miejsce, w którym da się to poprawić. Bez tego + ``SearchAnonThrottle`` (liczy po IP) zlewa wszystkich anonimów do jednego + kubełka (spec §7.3). + """ + + def __init__(self, aplikacja) -> None: + self._aplikacja = aplikacja + + async def __call__(self, scope, receive, send): + dane = biezace() # fail-closed + scope = dict(scope) + scope["client"] = (dane.ip, 0) + await self._aplikacja(scope, receive, send) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest src/mcp_server/tests/test_klient.py -v` +Expected: PASS (2 passed) + +- [ ] **Step 5: Commit** + +```bash +git add src/mcp_server/kontekst.py src/mcp_server/klient.py src/mcp_server/tests/test_klient.py +git commit -m "feat(mcp_server): kontekst żądania + KlientScope z prawdziwym IP" +``` + +--- + +## Task 4: `BppClientInProcess` — klient per żądanie z budżetem czasu + +**Files:** +- Modify: `src/mcp_server/klient.py` +- Test: `src/mcp_server/tests/test_klient.py` (dopisanie) + +**Interfaces:** +- Consumes: `kontekst.biezace()`, `KlientScope`. +- Produces: `klient.zbuduj_klienta() -> BppClientInProcess`, + `klient.BppClientInProcess`. + +- [ ] **Step 1: Write the failing test (dopisz do pliku)** + +```python +# --- dopisz na końcu src/mcp_server/tests/test_klient.py --- + +import time + +import httpx + +from mcp_server.klient import BppClientInProcess, zbuduj_klienta + + +def _dane(deadline=None, host="bpp.example.test"): + return DaneZadania( + host=host, scheme="https", ip="198.51.100.9", bearer=None, deadline=deadline + ) + + +def test_api_root_bierze_sie_z_hosta_zadania(): + """Sedno D9: adres API zależy od hosta KONKRETNEGO żądania (spec §5.3).""" + + async def scenariusz(): + zeton = dane_zadania.set(_dane(host="bpp.umlub.pl")) + try: + klient = zbuduj_klienta() + return str(klient._full_url("autor/")) + finally: + dane_zadania.reset(zeton) + + assert uruchom(scenariusz) == "https://bpp.umlub.pl/api/v1/autor/" + + +def test_dwa_zadania_dwa_rozne_adresy(): + async def scenariusz(): + adresy = [] + for host in ("bpp.a.test", "bpp.b.test"): + zeton = dane_zadania.set(_dane(host=host)) + try: + adresy.append(str(zbuduj_klienta()._full_url("autor/"))) + finally: + dane_zadania.reset(zeton) + return adresy + + a, b = uruchom(scenariusz) + assert a != b + assert "bpp.a.test" in a and "bpp.b.test" in b + + +def test_przekroczony_budzet_zatrzymuje_zadania(): + """REGRESJA BL-2: po wyczerpaniu budżetu klient NIE wykonuje kolejnych + żądań — limit ma zatrzymywać pracę, nie tylko przestać na nią czekać.""" + wywolania = [] + + def handler(request): + wywolania.append(request) + return httpx.Response(200, json={"ok": 1}) + + async def scenariusz(): + zeton = dane_zadania.set(_dane(deadline=time.monotonic() - 1)) + try: + klient = BppClientInProcess(transport=httpx.MockTransport(handler)) + with pytest.raises(Exception): + await klient.get_json("autor/1/") + finally: + dane_zadania.reset(zeton) + + uruchom(scenariusz) + assert wywolania == [] +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest src/mcp_server/tests/test_klient.py -v` +Expected: FAIL — `ImportError: cannot import name 'BppClientInProcess'` + +- [ ] **Step 3: Write implementation (dopisz do `klient.py`)** + +```python +# --- dopisz na końcu src/mcp_server/klient.py --- + +import time + +import httpx +from bpp_mcp.client import BppClient, BppError, TrybAuth +from bpp_mcp.config import Config + + +class BppClientInProcess(BppClient): + """``BppClient`` wołający własną aplikację Django, bez gniazda TCP. + + Tworzony PER ŻĄDANIE (spec D9): ``BppClient`` zapamiętuje ``api_root`` + w ``__init__``, a adres zależy od nagłówka ``Host`` konkretnego żądania. + Przy okazji rozwiązuje to izolację cache (świeży klient = świeży słownik) + i sprowadza semafor do zasięgu jednego wywołania narzędzia. + """ + + def __init__(self, *, transport=None) -> None: + dane = biezace() + if transport is None: + from django_bpp.asgi import django_asgi_app + + transport = httpx.ASGITransport( + app=KlientScope(django_asgi_app), + # 500 z aplikacji ma wrócić jako BppNetworkError z czytelnym + # komunikatem, a nie jako surowy traceback w wyniku narzędzia. + raise_app_exceptions=False, + ) + super().__init__( + Config(base_url=f"{dane.scheme}://{dane.host}", transport="stdio"), + transport=transport, + tryb_auth=TrybAuth.W_PROCESIE, + max_retries=0, # nie ma sieci, która mogłaby zamigotać + ) + # Przekierowania WYŁĄCZONE (spec D12): SECURE_SSL_REDIRECT po cichu + # podwajałby każde żądanie, a FirstRunWizardMiddleware przekierowuje + # /api/v1/ na HTML kreatora, co kończyłoby się ValueError w resp.json(). + self._client.follow_redirects = False + + async def _request(self, full, *, retry_5xx=True): + """Egzekwuj budżet czasu PRZED wykonaniem żądania. + + Limit wokół aplikacji MCP nie działa (spec §5.2, BL-2): handler biegnie + w zadaniu grupy menedżera, więc anulowanie zadania żądania odcina tylko + odpowiedź. Sprawdzenie musi być tutaj, gdzie faktycznie wykonuje się + praca. + """ + dane = biezace() + if dane.deadline is not None and time.monotonic() >= dane.deadline: + raise BppError( + "Przekroczono budżet czasu wywołania narzędzia — zawęź " + "zapytanie albo poproś o mniej danych." + ) + if not str(full.path).startswith("/api/v1/"): + raise BppError(f"Ścieżka spoza /api/v1/ jest niedozwolona: {full.path}") + if full.host and full.host != dane.host: + raise BppError(f"Host spoza bieżącego żądania jest niedozwolony: {full.host}") + return await super()._request(full, retry_5xx=retry_5xx) + + +def zbuduj_klienta() -> BppClientInProcess: + """Zbuduj klienta dla bieżącego żądania.""" + return BppClientInProcess() +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest src/mcp_server/tests/test_klient.py -v` +Expected: PASS (5 passed) + +- [ ] **Step 5: Commit** + +```bash +git add src/mcp_server/klient.py src/mcp_server/tests/test_klient.py +git commit -m "feat(mcp_server): BppClientInProcess per żądanie z budżetem czasu" +``` + +--- + +## Task 5: `BramkaBearera` — 401 z `WWW-Authenticate` + +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. Piszemy własną warstwę (spec §5.4, D10). + +**Files:** +- Create: `src/mcp_server/auth.py` +- Test: `src/mcp_server/tests/test_auth.py` + +**Interfaces:** +- Produces: `auth.BramkaBearera(app, *, wymagany: bool)`, + `auth.zweryfikuj_token(raw) -> User | None` (async). + +- [ ] **Step 1: Write the failing test** + +```python +# src/mcp_server/tests/test_auth.py +"""BramkaBearera: 401 z WWW-Authenticate, przy zachowaniu dostępu anonimowego. + +Bramka MUSI sprawdzać dokładnie to, co StrictOAuth2Authentication: ważność +tokenu, aktywność konta ORAZ scope ``read``. Gdyby sprawdzała mniej, token bez +scope'u przeszedłby tutaj i padł dopiero w DRF — czyli wróciłby jako błąd +w treści JSON-RPC z HTTP 200 i klient nigdy nie ponowiłby autoryzacji +(spec §5.4). +""" + +import pytest +from django.contrib.auth import get_user_model +from model_bakery import baker +from oauth2_provider.models import get_access_token_model, get_application_model + +from mcp_server.auth import BramkaBearera +from mcp_server.tests.utils import uruchom, wywolaj, zbuduj_scope + + +async def _ok(scope, receive, send): + await send({"type": "http.response.start", "status": 200, "headers": []}) + await send({"type": "http.response.body", "body": b"PRZESZLO"}) + + +def _token(scope="read", aktywny=True, wygasly=False): + from datetime import timedelta + + from django.utils import timezone + + user = baker.make(get_user_model(), is_active=aktywny) + app = get_application_model().objects.create( + name="test", client_type="public", authorization_grant_type="authorization-code" + ) + return get_access_token_model().objects.create( + user=user, + application=app, + token="TOKEN123", + scope=scope, + expires=timezone.now() + timedelta(seconds=-1 if wygasly else 3600), + ) + + +@pytest.mark.django_db(transaction=True) +def test_publiczny_bez_naglowka_przepuszcza(): + app = BramkaBearera(_ok, wymagany=False) + status, _, tresc = uruchom(lambda: wywolaj(app, zbuduj_scope("/mcp"))) + assert status == 200 and tresc == "PRZESZLO" + + +@pytest.mark.django_db(transaction=True) +def test_wymagany_bez_naglowka_daje_401_z_naglowkiem(): + app = BramkaBearera(_ok, wymagany=True) + status, naglowki, _ = uruchom(lambda: wywolaj(app, zbuduj_scope("/mcp/auth"))) + assert status == 401 + assert "resource_metadata=" in naglowki["www-authenticate"] + + +@pytest.mark.django_db(transaction=True) +def test_zly_token_daje_401_takze_na_publicznym(): + app = BramkaBearera(_ok, wymagany=False) + scope = zbuduj_scope("/mcp", naglowki={"authorization": "Bearer NIEISTNIEJE"}) + status, _, _ = uruchom(lambda: wywolaj(app, scope)) + assert status == 401 + + +@pytest.mark.django_db(transaction=True) +def test_token_bez_scope_read_odrzucony_juz_w_bramce(): + _token(scope="write") + app = BramkaBearera(_ok, wymagany=False) + scope = zbuduj_scope("/mcp", naglowki={"authorization": "Bearer TOKEN123"}) + status, _, _ = uruchom(lambda: wywolaj(app, scope)) + assert status == 401 + + +@pytest.mark.django_db(transaction=True) +def test_konto_nieaktywne_odrzucone(): + _token(aktywny=False) + app = BramkaBearera(_ok, wymagany=False) + scope = zbuduj_scope("/mcp", naglowki={"authorization": "Bearer TOKEN123"}) + status, _, _ = uruchom(lambda: wywolaj(app, scope)) + assert status == 401 + + +@pytest.mark.django_db(transaction=True) +def test_wazny_token_przechodzi(): + _token() + app = BramkaBearera(_ok, wymagany=False) + scope = zbuduj_scope("/mcp", naglowki={"authorization": "Bearer TOKEN123"}) + status, _, tresc = uruchom(lambda: wywolaj(app, scope)) + assert status == 200 and tresc == "PRZESZLO" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest src/mcp_server/tests/test_auth.py -v` +Expected: FAIL — `ModuleNotFoundError: mcp_server.auth` + +- [ ] **Step 3: Write implementation** + +```python +# src/mcp_server/auth.py +"""Weryfikacja bearera przed aplikacją MCP. + +SDK MCP montuje ``RequireAuthMiddleware`` wyłącznie przy podanym +``token_verifier``, a ten odrzuca 401-ką KAŻDE żądanie bez tożsamości — czyli +wyklucza dostęp anonimowy, na którym nam zależy. Stąd własna warstwa +(spec §5.4). +""" + +from __future__ import annotations + +import json + +from asgiref.sync import sync_to_async +from django.db import close_old_connections + +WYMAGANY_SCOPE = "read" + + +def _sprawdz(raw: str) -> bool: + """Czy token jest ważny wg TYCH SAMYCH reguł, co DRF. + + ``StrictOAuth2Authentication`` wymaga ważnego tokenu, aktywnego konta ORAZ + scope'u ``read``. Sprawdzanie tu mniej oznaczałoby, że token bez scope'u + przechodzi bramkę i pada dopiero w DRF — czyli wraca jako błąd w treści + JSON-RPC z HTTP 200, a klient nigdy nie ponawia autoryzacji. + """ + from oauth2_provider.models import get_access_token_model + + close_old_connections() # poza cyklem żądania Django — brak sygnałów + try: + token = ( + get_access_token_model() + .objects.select_related("user") + .filter(token=raw) + .first() + ) + if token is None or not token.is_valid(): + return False + if token.user is None or not token.user.is_active: + return False + return bool(token.allow_scopes([WYMAGANY_SCOPE])) + finally: + close_old_connections() + + +zweryfikuj_token = sync_to_async(_sprawdz, thread_sensitive=False) + + +class BramkaBearera: + """Przepuszcza anonima (gdy wolno) i odrzuca nieważny token 401-ką.""" + + def __init__(self, aplikacja, *, wymagany: bool) -> None: + self._aplikacja = aplikacja + self._wymagany = wymagany + + async def __call__(self, scope, receive, send): + naglowki = {k.lower(): v for k, v in scope.get("headers", [])} + surowy = naglowki.get(b"authorization", b"").decode(errors="replace") + schemat, _, wartosc = surowy.partition(" ") + + if not surowy: + if self._wymagany: + await self._odmow(scope, send) + return + await self._aplikacja(scope, receive, send) + return + + if schemat.lower() != "bearer" or not await zweryfikuj_token(wartosc.strip()): + await self._odmow(scope, send) + return + + await self._aplikacja(scope, receive, send) + + async def _odmow(self, scope, send) -> None: + naglowki = {k.lower(): v for k, v in scope.get("headers", [])} + host = naglowki.get(b"host", b"localhost").decode() + schemat = "https" if scope.get("scheme") in ("https", "wss") else "http" + prm = f"{schemat}://{host}/.well-known/oauth-protected-resource" + cialo = json.dumps( + {"error": "invalid_token", "error_description": "Wymagany ważny token OAuth."} + ).encode() + await send( + { + "type": "http.response.start", + "status": 401, + "headers": [ + (b"content-type", b"application/json"), + (b"www-authenticate", f'Bearer resource_metadata="{prm}"'.encode()), + ], + } + ) + await send({"type": "http.response.body", "body": cialo}) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest src/mcp_server/tests/test_auth.py -v` +Expected: PASS (6 passed) + +- [ ] **Step 5: Commit** + +```bash +git add src/mcp_server/auth.py src/mcp_server/tests/test_auth.py +git commit -m "feat(mcp_server): BramkaBearera — 401 z PRM przy zachowaniu anonima" +``` + +--- + +## Task 6: `RouterHttp` i `LifespanMcp` + +**Files:** +- Create: `src/mcp_server/routing.py` +- Test: `src/mcp_server/tests/test_routing.py` + +**Interfaces:** +- Consumes: `StartMcp`, `BramkaBearera`, `kontekst.DaneZadania`. +- Produces: `routing.RouterHttp(mcp_app, django_app, start)`, + `routing.LifespanMcp(start)`. + +- [ ] **Step 1: Write the failing test** + +```python +# src/mcp_server/tests/test_routing.py +"""RouterHttp: dopasowanie DOKŁADNE + przepisanie ścieżki dla /mcp/auth. + +SDK montuje trasę jako Starlette ``Route`` (regex ^/mcp$), nie ``Mount``, więc +bez przepisania ścieżki /mcp/auth dostałoby 404 (spec §5.1, bloker BL-1). +Dopasowanie prefiksowe byłoby dodatkowo sprzeczne z §8: /mcp/ ma iść do Django. +""" + +from mcp_server.routing import LifespanMcp, RouterHttp +from mcp_server.start import StartMcp +from mcp_server.tests.utils import uruchom, wywolaj, zbuduj_scope + + +class _AtrapaLifespanu: + def __init__(self): + self.router = self + + def lifespan_context(self, _app): + from contextlib import asynccontextmanager + + @asynccontextmanager + async def _ctx(): + yield + + return _ctx() + + +async def _echo_sciezki(scope, receive, send): + await send({"type": "http.response.start", "status": 200, "headers": []}) + await send({"type": "http.response.body", "body": b"MCP:" + scope["path"].encode()}) + + +async def _django(scope, receive, send): + await send({"type": "http.response.start", "status": 200, "headers": []}) + await send({"type": "http.response.body", "body": b"DJANGO:" + scope["path"].encode()}) + + +def _router(): + return RouterHttp(_echo_sciezki, _django, StartMcp(_AtrapaLifespanu())) + + +def test_mcp_trafia_do_aplikacji_mcp(): + status, _, tresc = uruchom(lambda: wywolaj(_router(), zbuduj_scope("/mcp"))) + assert status == 200 and tresc == "MCP:/mcp" + + +def test_mcp_auth_ma_przepisana_sciezke_na_mcp(): + """REGRESJA BL-1: bez przepisania SDK oddałoby 404.""" + scope = zbuduj_scope("/mcp/auth") + status, _, tresc = uruchom(lambda: wywolaj(_router(), scope)) + assert status == 200 and tresc == "MCP:/mcp" + + +def test_mcp_ze_slashem_idzie_do_django(): + status, _, tresc = uruchom( + lambda: wywolaj(_router(), zbuduj_scope("/mcp/", metoda="GET")) + ) + assert tresc == "DJANGO:/mcp/" + + +def test_reszta_idzie_do_django(): + status, _, tresc = uruchom( + lambda: wywolaj(_router(), zbuduj_scope("/api/v1/", metoda="GET")) + ) + assert tresc == "DJANGO:/api/v1/" + + +def test_get_mcp_z_przegladarki_przekierowuje_na_slash(): + """Człowiek wkleja adres z instrukcji; bez tego SDK oddałby 406.""" + scope = zbuduj_scope("/mcp", metoda="GET", naglowki={"accept": "text/html"}) + status, naglowki, _ = uruchom(lambda: wywolaj(_router(), scope)) + assert status == 307 and naglowki["location"] == "/mcp/" + + +def test_lifespan_startup_complete(): + start = StartMcp(_AtrapaLifespanu()) + app = LifespanMcp(start) + + async def scenariusz(): + zdarzenia = [{"type": "lifespan.startup"}, {"type": "lifespan.shutdown"}] + odpowiedzi = [] + + async def receive(): + return zdarzenia.pop(0) + + async def send(m): + odpowiedzi.append(m) + + await app({"type": "lifespan"}, receive, send) + return [m["type"] for m in odpowiedzi] + + assert uruchom(scenariusz) == [ + "lifespan.startup.complete", + "lifespan.shutdown.complete", + ] +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest src/mcp_server/tests/test_routing.py -v` +Expected: FAIL — `ModuleNotFoundError: mcp_server.routing` + +- [ ] **Step 3: Write implementation** + +```python +# src/mcp_server/routing.py +"""Routing ASGI: rozdział /mcp od reszty serwisu oraz obsługa lifespanu.""" + +from __future__ import annotations + +import time + +from django.conf import settings + +from django_bpp.client_ip import get_client_ip +from mcp_server.auth import BramkaBearera +from mcp_server.kontekst import DaneZadania, dane_zadania +from mcp_server.start import StartMcp + +#: Budżet czasu jednego wywołania narzędzia (spec D11). +BUDZET_SEKUND = getattr(settings, "MCP_BUDZET_SEKUND", 25.0) + +SCIEZKA_PUBLICZNA = "/mcp" +SCIEZKA_Z_LOGOWANIEM = "/mcp/auth" + + +class LifespanMcp: + """Obsługuje scope ``lifespan``, którego ProtocolTypeRouter nie zna. + + Klucza ``"lifespan"`` po prostu nie ma dziś w mapie routera, więc uvicorn + dostaje ValueError, loguje „lifespan appears unsupported" i jedzie dalej — + a menedżer sesji MCP nigdy nie wstaje (spec §2.4). + """ + + def __init__(self, start: StartMcp) -> None: + self._start = start + + async def __call__(self, scope, receive, send): + while True: + komunikat = await receive() + if komunikat["type"] == "lifespan.startup": + try: + await self._start.zapewnij() + except Exception as exc: + # Świadomie NIE połykamy: pod gunicornem worker padnie + # i zostanie respawnowany, czyli awaria będzie widoczna + # jako boot-loop, a nie jako cisza (spec §5.1). + await send( + {"type": "lifespan.startup.failed", "message": str(exc)} + ) + return + await send({"type": "lifespan.startup.complete"}) + elif komunikat["type"] == "lifespan.shutdown": + await send({"type": "lifespan.shutdown.complete"}) + return + + +class RouterHttp: + """Kieruje ``/mcp`` i ``/mcp/auth`` do MCP, resztę do Django.""" + + def __init__(self, mcp_app, django_app, start: StartMcp) -> None: + self._django = django_app + self._start = start + self._publiczny = BramkaBearera(mcp_app, wymagany=False) + self._z_logowaniem = BramkaBearera(mcp_app, wymagany=True) + + async def __call__(self, scope, receive, send): + sciezka = scope.get("path", "") + if sciezka not in (SCIEZKA_PUBLICZNA, SCIEZKA_Z_LOGOWANIEM): + await self._django(scope, receive, send) + return + + if scope.get("method") == "GET" and self._chce_html(scope): + await self._przekieruj(send, "/mcp/") + return + + # PIERWSZA instrukcja i POZA jakimkolwiek cancel scope'em (spec §5.1). + await self._start.zapewnij() + if not self._start.zywy: + await self._niedostepny(send) + return + + scope = self._z_kontekstem(scope) + zeton = dane_zadania.set(self._dane(scope)) + try: + if sciezka == SCIEZKA_Z_LOGOWANIEM: + # SDK montuje trasę jako Route (^/mcp$), nie Mount — bez + # przepisania drugi adres dostałby 404 (spec §5.1, BL-1). + scope = dict(scope, path=SCIEZKA_PUBLICZNA, raw_path=b"/mcp") + await self._z_logowaniem(scope, receive, send) + else: + await self._publiczny(scope, receive, send) + finally: + dane_zadania.reset(zeton) + + @staticmethod + def _chce_html(scope) -> bool: + for klucz, wartosc in scope.get("headers", []): + if klucz.lower() == b"accept" and b"text/html" in wartosc.lower(): + return True + return False + + @staticmethod + def _z_kontekstem(scope): + return scope + + @staticmethod + def _dane(scope) -> DaneZadania: + naglowki = {k.lower(): v.decode() for k, v in scope.get("headers", [])} + host = naglowki.get("host", "localhost") + # nginx→uvicorn jest plaintext; prawdziwy schemat niesie + # X-Forwarded-Proto (spec §7.6). Nagłówka NIE przekazujemy dalej — + # służy wyłącznie do zbudowania adresu bazowego. + scheme = naglowki.get("x-forwarded-proto", scope.get("scheme", "http")) + bearer = None + surowy = naglowki.get("authorization", "") + if surowy.lower().startswith("bearer "): + bearer = surowy.split(" ", 1)[1].strip() + return DaneZadania( + host=host, + scheme="https" if scheme == "https" else "http", + ip=get_client_ip(_UdajeRequest(scope, naglowki)), + bearer=bearer, + deadline=time.monotonic() + BUDZET_SEKUND, + ) + + @staticmethod + async def _przekieruj(send, gdzie: str) -> None: + await send( + { + "type": "http.response.start", + "status": 307, + "headers": [(b"location", gdzie.encode())], + } + ) + await send({"type": "http.response.body", "body": b""}) + + @staticmethod + async def _niedostepny(send) -> None: + await send( + { + "type": "http.response.start", + "status": 503, + "headers": [(b"content-type", b"application/json")], + } + ) + await send( + {"type": "http.response.body", "body": b'{"error":"mcp_unavailable"}'} + ) + + +class _UdajeRequest: + """Minimalny obiekt zgodny z tym, czego oczekuje ``get_client_ip``.""" + + def __init__(self, scope, naglowki): + klient = scope.get("client") or ("", 0) + self.META = {"REMOTE_ADDR": klient[0]} + xff = naglowki.get("x-forwarded-for") + if xff: + self.META["HTTP_X_FORWARDED_FOR"] = xff +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest src/mcp_server/tests/test_routing.py -v` +Expected: PASS (6 passed) + +- [ ] **Step 5: Commit** + +```bash +git add src/mcp_server/routing.py src/mcp_server/tests/test_routing.py +git commit -m "feat(mcp_server): RouterHttp z przepisaniem /mcp/auth + LifespanMcp" +``` + +--- + +## Task 7: `build_application()` — aplikacja MCP z narzędziami + +**Files:** +- Create: `src/mcp_server/aplikacja.py` +- Test: `src/mcp_server/tests/test_aplikacja.py` (dopisanie) + +**Interfaces:** +- Consumes: `zbuduj_klienta`, `StartMcp`, `RouterHttp`, `LifespanMcp`. +- Produces: `aplikacja.build_application() -> (mcp_app, start)`, + `aplikacja.KontekstZadania`. + +- [ ] **Step 1: Write the failing test (dopisz do pliku)** + +```python +# --- dopisz na końcu src/mcp_server/tests/test_aplikacja.py --- + +from mcp_server.aplikacja import build_application +from mcp_server.tests.utils import uruchom + + +def test_zarejestrowano_komplet_narzedzi(): + """11 narzędzi + 1 prompt — tyle daje register_tools z bpp-mcp 0.4.0.""" + mcp_app, _ = build_application() + + async def scenariusz(): + serwer = mcp_app.state.serwer_mcp + return len(await serwer.list_tools()), len(await serwer.list_prompts()) + + narzedzia, prompty = uruchom(scenariusz) + assert narzedzia == 11 + assert prompty == 1 + + +def test_fabryka_daje_nowa_instancje_za_kazdym_razem(): + """Aplikacja MUSI powstawać fabryką: allowlista hostów liczona przy + imporcie nie zobaczyłaby settings nadpisanych w teście (spec §11).""" + a, _ = build_application() + b, _ = build_application() + assert a is not b +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest src/mcp_server/tests/test_aplikacja.py -v` +Expected: FAIL — `ModuleNotFoundError: mcp_server.aplikacja` + +- [ ] **Step 3: Write implementation** + +```python +# src/mcp_server/aplikacja.py +"""Budowa aplikacji MCP: serwer SDK + narzędzia z bpp-mcp + polityka hostów.""" + +from __future__ import annotations + +from contextlib import asynccontextmanager + +from bpp_mcp import register_tools +from django.conf import settings +from mcp.server.mcpserver import MCPServer +from mcp.server.transport_security import TransportSecuritySettings + +from mcp_server.klient import zbuduj_klienta +from mcp_server.start import StartMcp + + +class KontekstZadania: + """Kontrakt lifespanu ``register_tools``, ale klient jest PER ŻĄDANIE. + + ``register_tools`` dokumentuje, że lifespan ma oddać ``KontekstApp`` *albo + obiekt o tych samych atrybutach*. Korzystamy z tej furtki: ``client`` jest + property, więc wrappery narzędzi — które czytają go przy każdym wywołaniu — + dostają klienta zbudowanego dla bieżącego żądania (spec D9). + """ + + #: Host wielo-użytkownikowy: token jednej osoby nie może trafić do żądania + #: innej, więc NIGDY nie sięgamy do lokalnego cache tokenów. + bearer_provider = None + + @property + def client(self): + return zbuduj_klienta() + + +def _dozwolone_hosty() -> list[str]: + """Przetłumacz ``ALLOWED_HOSTS`` na semantykę SDK. + + SDK dopasowuje host dokładnie albo wzorcem ``host:*`` — Django-owe + ``.domena`` i ``*`` nie działają (spec §6.1). ``*`` w ALLOWED_HOSTS + (używane w testach) mapujemy na wyłączenie sprawdzania, bo nie ma dla + niego odpowiednika. + """ + hosty: list[str] = [] + for wpis in settings.ALLOWED_HOSTS: + if wpis == "*": + return [] + hosty.append(wpis.lstrip(".")) + hosty.append(f"{wpis.lstrip('.')}:*") + return hosty + + +def build_application(): + """Zbuduj aplikację ASGI serwera MCP i jej ``StartMcp``. + + FABRYKA, nie moduł: allowlista hostów jest liczona z ``settings``, więc + zbudowanie aplikacji przy imporcie uniemożliwiłoby testom nadpisanie + ``ALLOWED_HOSTS`` (spec §11). + """ + + @asynccontextmanager + async def lifespan(_serwer): + yield KontekstZadania() + + serwer = MCPServer("bpp", version="1", lifespan=lifespan) + register_tools(serwer) + + hosty = _dozwolone_hosty() + bezpieczenstwo = TransportSecuritySettings( + enable_dns_rebinding_protection=bool(hosty), + allowed_hosts=hosty, + allowed_origins=[f"https://{h}" for h in hosty if not h.endswith(":*")], + ) + + aplikacja = serwer.streamable_http_app( + streamable_http_path="/mcp", + # Bezstanowo: recykling workera (--max-requests) zabiłby sesję + # stateful w środku pracy użytkownika. json_response znosi SSE, więc + # proxy_read_timeout nginksa przestaje być tematem (spec D3). + stateless_http=True, + json_response=True, + transport_security=bezpieczenstwo, + ) + aplikacja.state.serwer_mcp = serwer + return aplikacja, StartMcp(aplikacja) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest src/mcp_server/tests/test_aplikacja.py -v` +Expected: PASS (4 passed) + +- [ ] **Step 5: Commit** + +```bash +git add src/mcp_server/aplikacja.py src/mcp_server/tests/test_aplikacja.py +git commit -m "feat(mcp_server): build_application z narzędziami i polityką hostów" +``` + +--- + +## Task 8: Montaż w `asgi.py` — test end-to-end + +**Files:** +- Modify: `src/django_bpp/asgi.py` +- Test: `src/mcp_server/tests/test_e2e.py` + +**Interfaces:** +- Consumes: `build_application`, `RouterHttp`, `LifespanMcp`. +- Produces: `django_bpp.asgi.application` z kluczem `"lifespan"`. + +- [ ] **Step 1: Write the failing test** + +```python +# src/mcp_server/tests/test_e2e.py +"""Pełna ścieżka: POST /mcp → initialize → tools/list, bez sieci.""" + +import pytest + +from mcp_server.aplikacja import build_application +from mcp_server.routing import RouterHttp +from mcp_server.start import StartMcp +from mcp_server.tests.utils import uruchom, wywolaj, zbuduj_scope + + +async def _django(scope, receive, send): + await send({"type": "http.response.start", "status": 404, "headers": []}) + await send({"type": "http.response.body", "body": b""}) + + +def _router(settings): + settings.ALLOWED_HOSTS = ["bpp.example.test"] + mcp_app, start = build_application() + return RouterHttp(mcp_app, _django, start) + + +@pytest.mark.django_db(transaction=True) +def test_initialize_zwraca_serverinfo(settings): + router = _router(settings) + scope = zbuduj_scope( + "/mcp", + naglowki={ + "content-type": "application/json", + "accept": "application/json, text/event-stream", + }, + ) + cialo = { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": {"name": "test", "version": "0"}, + }, + } + status, _, tresc = uruchom(lambda: wywolaj(router, scope, cialo)) + assert status == 200 + assert '"serverInfo"' in tresc and '"bpp"' in tresc + + +@pytest.mark.django_db(transaction=True) +def test_zle_host_daje_421(settings): + """Ochrona przed DNS-rebinding SDK działa niezależnie od ALLOWED_HOSTS.""" + router = _router(settings) + scope = zbuduj_scope( + "/mcp", + host="zly.host", + naglowki={"content-type": "application/json"}, + ) + status, _, _ = uruchom( + lambda: wywolaj(router, scope, {"jsonrpc": "2.0", "id": 1, "method": "ping"}) + ) + assert status in (403, 421) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest src/mcp_server/tests/test_e2e.py -v` +Expected: FAIL — brak `state.serwer_mcp` / brak montażu. + +- [ ] **Step 3: Zmodyfikuj `src/django_bpp/asgi.py`** + +Zastąp blok `application = ProtocolTypeRouter({...})` na końcu pliku: + +```python +# Aplikacja MCP i jej routing. Import PO get_asgi_application() — mcp_server +# sięga po modele przez klienta w procesie. +from mcp_server.aplikacja import build_application # noqa: E402 +from mcp_server.routing import LifespanMcp, RouterHttp # noqa: E402 + +_mcp_app, _mcp_start = build_application() + +application = ProtocolTypeRouter( + { + "http": RouterHttp(_mcp_app, django_asgi_app, _mcp_start), + "websocket": AllowedHostsOriginValidator( + AuthMiddlewareStack(URLRouter(websocket_urlpatterns)) + ), + # 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 (spec §2.4, §5.1). + "lifespan": LifespanMcp(_mcp_start), + } +) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest src/mcp_server/tests/test_e2e.py -v` +Expected: PASS (2 passed) + +- [ ] **Step 5: Sprawdź, że nic się nie zepsuło** + +Run: `uv run pytest src/django_bpp/tests/ src/oauth_mcp/ -q` +Expected: PASS bez zmian w tych plikach. + +- [ ] **Step 6: Commit** + +```bash +git add src/django_bpp/asgi.py src/mcp_server/tests/test_e2e.py +git commit -m "feat(asgi): zamontuj /mcp obok Django, dopisz klucz lifespan" +``` + +--- + +## Task 9: Metadane Protected Resource (RFC 9728) + +**Files:** +- Modify: `src/oauth_mcp/views_metadata.py` +- Modify: `src/oauth_mcp/urls.py` +- Test: `src/oauth_mcp/tests/test_metadata_prm.py` + +**Interfaces:** +- Produces: `GET /.well-known/oauth-protected-resource`. + +- [ ] **Step 1: Write the failing test** + +```python +# src/oauth_mcp/tests/test_metadata_prm.py +"""PRM (RFC 9728) — wskazuje klientowi, gdzie jest serwer autoryzacji.""" + +import pytest + + +@pytest.mark.django_db +def test_prm_zwraca_wlasciwe_urle(client): + odp = client.get( + "/.well-known/oauth-protected-resource", HTTP_HOST="bpp.example.test" + ) + assert odp.status_code == 200 + dane = odp.json() + assert dane["resource"] == "http://bpp.example.test/mcp" + assert dane["authorization_servers"] == ["http://bpp.example.test"] + assert dane["scopes_supported"] == ["read"] + + +@pytest.mark.django_db +def test_prm_jest_per_host(client): + """Wielo-domenowość: każda uczelnia widzi swój adres.""" + a = client.get( + "/.well-known/oauth-protected-resource", HTTP_HOST="uczelnia1.localhost" + ).json() + b = client.get( + "/.well-known/oauth-protected-resource", HTTP_HOST="uczelnia2.localhost" + ).json() + assert a["resource"] != b["resource"] +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest src/oauth_mcp/tests/test_metadata_prm.py -v` +Expected: FAIL — 404. + +- [ ] **Step 3: Dopisz widok do `src/oauth_mcp/views_metadata.py`** + +```python +def oauth_protected_resource_metadata(request): + """RFC 9728 — mówi klientowi MCP, który serwer autoryzacji obsługuje /mcp. + + Poprzedni spec przypisywał ten dokument pakietowi bpp-mcp; odkąd Resource + Serverem jest sama instancja BPP, mieszka on tutaj. URL-e przez + build_absolute_uri — poprawny scheme z SECURE_PROXY_SSL_HEADER, host + per-request (wielo-domenowość). + """ + return JsonResponse( + { + "resource": request.build_absolute_uri("/mcp"), + "authorization_servers": [ + request.build_absolute_uri("/").rstrip("/") + ], + "scopes_supported": ["read"], + "bearer_methods_supported": ["header"], + } + ) +``` + +- [ ] **Step 4: Dopisz trasę w `src/oauth_mcp/urls.py`** + +W `urlpatterns`, po wpisie `oauth-as-metadata`: + +```python + path( + ".well-known/oauth-protected-resource", + oauth_protected_resource_metadata, + name="oauth-prm", + ), +``` + +oraz rozszerz import na górze pliku: + +```python +from oauth_mcp.views_metadata import ( + oauth_authorization_server_metadata, + oauth_protected_resource_metadata, +) +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `uv run pytest src/oauth_mcp/tests/test_metadata_prm.py -v` +Expected: PASS (2 passed) + +- [ ] **Step 6: Commit** + +```bash +git add src/oauth_mcp/views_metadata.py src/oauth_mcp/urls.py src/oauth_mcp/tests/test_metadata_prm.py +git commit -m "feat(oauth_mcp): metadane protected-resource (RFC 9728)" +``` + +--- + +## Task 10: Strona `/mcp/` dla człowieka + +**Files:** +- Create: `src/mcp_server/views.py`, `src/mcp_server/urls.py` +- Create: `src/mcp_server/templates/mcp_server/index.html` +- Modify: `src/django_bpp/urls.py` +- Test: `src/mcp_server/tests/test_strona.py` + +- [ ] **Step 1: Write the failing test** + +```python +# src/mcp_server/tests/test_strona.py +"""Strona /mcp/ — instrukcja podłączenia dla człowieka.""" + +import pytest + + +@pytest.mark.django_db +def test_strona_pokazuje_oba_adresy(client): + odp = client.get("/mcp/", HTTP_HOST="bpp.example.test") + assert odp.status_code == 200 + tresc = odp.content.decode() + assert "http://bpp.example.test/mcp" in tresc + assert "http://bpp.example.test/mcp/auth" in tresc + + +@pytest.mark.django_db +def test_adresy_sa_per_host(client): + a = client.get("/mcp/", HTTP_HOST="uczelnia1.localhost").content.decode() + assert "uczelnia1.localhost/mcp" in a +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest src/mcp_server/tests/test_strona.py -v` +Expected: FAIL — 404. + +- [ ] **Step 3: Write implementation** + +```python +# src/mcp_server/views.py +from django.views.generic import TemplateView + + +class StronaMcp(TemplateView): + """Instrukcja podłączenia klienta AI — adresy składane per host.""" + + template_name = "mcp_server/index.html" + + def get_context_data(self, **kwargs): + kontekst = super().get_context_data(**kwargs) + kontekst["adres_publiczny"] = self.request.build_absolute_uri("/mcp") + kontekst["adres_z_logowaniem"] = self.request.build_absolute_uri("/mcp/auth") + return kontekst +``` + +```python +# src/mcp_server/urls.py +from django.urls import path + +from mcp_server.views import StronaMcp + +app_name = "mcp_server" + +urlpatterns = [ + path("", StronaMcp.as_view(), name="strona"), +] +``` + +```html +{# src/mcp_server/templates/mcp_server/index.html #} +{% extends "base.html" %} +{% load i18n %} + +{% block content %} +
+
+

{% trans "Bibliografia w Twoim asystencie AI" %}

+ +

{% trans "Wklej adres poniżej do klienta AI obsługującego zdalne serwery MCP." %}

+ +

{% trans "Dostęp publiczny" %}

+

{{ adres_publiczny }}

+

{% trans "Bez logowania. Widzi to samo, co publiczna część bibliografii." %}

+ +

{% trans "Dostęp z logowaniem" %}

+

{{ adres_z_logowaniem }}

+

{% trans "Poprosi o zalogowanie w przeglądarce. Odblokowuje dane niepubliczne." %}

+ +

{% trans "Claude Code" %}

+
claude mcp add bpp --transport http {{ adres_publiczny }}
+
+
+{% endblock %} +``` + +- [ ] **Step 4: Zamontuj w `src/django_bpp/urls.py`** + +W `urlpatterns`, obok innych `path(...)`: + +```python + path("mcp/", include("mcp_server.urls", namespace="mcp_server")), +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `uv run pytest src/mcp_server/tests/test_strona.py -v` +Expected: PASS (2 passed) + +- [ ] **Step 6: Commit** + +```bash +git add src/mcp_server/views.py src/mcp_server/urls.py src/mcp_server/templates src/django_bpp/urls.py src/mcp_server/tests/test_strona.py +git commit -m "feat(mcp_server): strona /mcp/ z instrukcją podłączenia" +``` + +--- + +## Task 11: Testy bezpieczeństwa i wielo-hostowe + +Każdy z tych testów pilnuje konkretnej dziury z recenzji specu. Bez nich +projekt nie ma prawa wejść. + +**Files:** +- Test: `src/mcp_server/tests/test_bezpieczenstwo.py` +- Test: `src/mcp_server/tests/test_wielohost.py` + +- [ ] **Step 1: Write the tests** + +```python +# src/mcp_server/tests/test_bezpieczenstwo.py +"""Regresje bezpieczeństwa warstwy /mcp (spec §7).""" + +import httpx +import pytest + +from mcp_server.kontekst import DaneZadania, dane_zadania +from mcp_server.klient import BppClientInProcess +from mcp_server.tests.utils import uruchom + + +def _dane(host="bpp.example.test", bearer=None): + return DaneZadania( + host=host, scheme="https", ip="198.51.100.9", bearer=bearer, deadline=None + ) + + +def _przechwytujacy(): + zebrane = [] + + def handler(request): + zebrane.append(request) + return httpx.Response(200, json={"ok": 1}) + + return httpx.MockTransport(handler), zebrane + + +def test_cookie_nie_trafia_do_zadania_wewnetrznego(): + """Cookie uwierzytelniłoby sesją przez SessionAuthentication — z pełnymi + uprawnieniami, bez zgody, bez scope i bez revoke (spec §7.1).""" + transport, zebrane = _przechwytujacy() + + async def scenariusz(): + zeton = dane_zadania.set(_dane()) + try: + klient = BppClientInProcess(transport=transport) + await klient.get_json("autor/1/") + finally: + dane_zadania.reset(zeton) + + uruchom(scenariusz) + assert "cookie" not in {k.lower() for k in zebrane[0].headers} + + +def test_basic_nie_trafia_do_zadania_wewnetrznego(): + """BasicAuthentication jest trzecia w DEFAULT_AUTHENTICATION_CLASSES, + a ApiReadOnlyForBearerMiddleware sprawdza tylko prefiks 'bearer ', więc + dla Basica warstwa read-only NIE istnieje (spec §7.1).""" + transport, zebrane = _przechwytujacy() + + async def scenariusz(): + zeton = dane_zadania.set(_dane(bearer=None)) + try: + klient = BppClientInProcess(transport=transport) + await klient.get_json("autor/1/") + finally: + dane_zadania.reset(zeton) + + uruchom(scenariusz) + naglowek = zebrane[0].headers.get("authorization", "") + assert not naglowek.lower().startswith("basic") + + +def test_sciezka_spoza_api_v1_odrzucona(): + transport, _ = _przechwytujacy() + + async def scenariusz(): + zeton = dane_zadania.set(_dane()) + try: + klient = BppClientInProcess(transport=transport) + with pytest.raises(Exception): + await klient.get_json("https://bpp.example.test/admin/") + finally: + dane_zadania.reset(zeton) + + uruchom(scenariusz) + + +def test_obcy_host_w_url_odrzucony(): + """_full_url przyjmuje URL-e bezwzględne wprost, a paginacyjne `next` je + niosą — kontrola musi obejmować host, nie tylko prefiks (spec §7.4).""" + transport, _ = _przechwytujacy() + + async def scenariusz(): + zeton = dane_zadania.set(_dane(host="bpp.a.test")) + try: + klient = BppClientInProcess(transport=transport) + with pytest.raises(Exception): + await klient.get_json("https://bpp.zly.test/api/v1/autor/1/") + finally: + dane_zadania.reset(zeton) + + uruchom(scenariusz) +``` + +```python +# src/mcp_server/tests/test_wielohost.py +"""Wewnętrzne żądanie musi nieść Host zewnętrznego — inaczej Uczelnia=None, +BramkaApiV1 przepuszcza bezwarunkowo i wyciekają ukryte statusy (spec §7.2).""" + +import pytest + +from mcp_server.kontekst import DaneZadania, dane_zadania +from mcp_server.klient import BppClientInProcess +from mcp_server.tests.utils import uruchom + +pytestmark = pytest.mark.django_db(transaction=True) + + +def _pobierz(host): + async def scenariusz(): + zeton = dane_zadania.set( + DaneZadania( + host=host, scheme="http", ip="198.51.100.9", bearer=None, deadline=None + ) + ) + try: + klient = BppClientInProcess() + return await klient.get_json("uczelnia/") + finally: + dane_zadania.reset(zeton) + + return uruchom(scenariusz) + + +def test_kazdy_host_widzi_swoja_uczelnie(settings, uczelnia1, uczelnia2): + settings.ALLOWED_HOSTS = ["uczelnia1.localhost", "uczelnia2.localhost"] + a = _pobierz("uczelnia1.localhost") + b = _pobierz("uczelnia2.localhost") + assert a["results"][0]["nazwa"] != b["results"][0]["nazwa"] + + +def test_wylaczone_api_odmawia_takze_przez_mcp(settings, uczelnia1): + """Dowód, że BramkaApiV1 działa na tej ścieżce.""" + settings.ALLOWED_HOSTS = ["uczelnia1.localhost"] + uczelnia1.api_v1_wlaczone = False + uczelnia1.save(update_fields=["api_v1_wlaczone"]) + with pytest.raises(Exception): + _pobierz("uczelnia1.localhost") +``` + +- [ ] **Step 2: Run tests** + +Run: `uv run pytest src/mcp_server/tests/test_bezpieczenstwo.py src/mcp_server/tests/test_wielohost.py -v` +Expected: PASS. Jeśli któryś padnie — **to jest realna dziura**, napraw kod, +nie test. + +- [ ] **Step 3: Commit** + +```bash +git add src/mcp_server/tests/test_bezpieczenstwo.py src/mcp_server/tests/test_wielohost.py +git commit -m "test(mcp_server): regresje bezpieczeństwa i wielo-hostowe" +``` + +--- + +## Task 12: Rollbar, healthcheck, newsfragment, dokumentacja + +**Files:** +- Modify: `src/mcp_server/aplikacja.py` (wrapper Rollbar) +- Create: `src/mcp_server/zdrowie.py` +- Create: `src/bpp/newsfragments/mcp-hostowany.feature.rst` +- Modify: `docs/deweloper/mapa-kodu.md` + +- [ ] **Step 1: Wrapper Rollbar w `aplikacja.py`** + +SDK zamienia każdy wyjątek narzędzia na `CallToolResult(is_error=True)`, więc +do warstwy ASGI **nic nie dociera** — bez tego awarie narzędzi są całkowicie +niewidoczne w monitoringu (spec §7.5). + +Dopisz przed `register_tools(serwer)`: + +```python +def _z_raportowaniem(serwer): + """Owija zarejestrowane narzędzia raportowaniem do Rollbara. + + SDK przechwytuje wyjątki handlerów i zamienia je na wynik z flagą błędu, + więc CustomRollbarNotifierMiddleware ich nie zobaczy — musimy zgłosić je + sami, zanim SDK je pochłonie (spec §7.5). + """ + import functools + + import rollbar + + oryginalny = serwer.tool + + def tool(*args, **kwargs): + dekorator = oryginalny(*args, **kwargs) + + def opakuj(fn): + @functools.wraps(fn) + async def wrapper(*a, **kw): + try: + return await fn(*a, **kw) + except Exception: + rollbar.report_exc_info() + raise + + return dekorator(wrapper) + + return opakuj + + serwer.tool = tool + return serwer +``` + +i zmień wywołanie na: + +```python + register_tools(_z_raportowaniem(serwer)) +``` + +- [ ] **Step 2: Healthcheck** + +```python +# src/mcp_server/zdrowie.py +"""Stan endpointu MCP dla monitoringu. + +Grupa zadań menedżera może zostać zatruta wyjątkiem dziecka — proces wtedy +żyje, ale każde /mcp zwraca błąd aż do recyklingu workera. Healthcheck musi +więc odróżniać „proces działa" od „endpoint MCP działa" (spec §10.3). +""" + +from __future__ import annotations + + +def stan_mcp() -> dict: + """Zwróć stan startu serwera MCP.""" + from django_bpp.asgi import _mcp_start + + return { + "wystartowany": _mcp_start.wystartowany, + "zywy": _mcp_start.zywy, + } +``` + +- [ ] **Step 3: Newsfragment** + +```rst +.. src/bpp/newsfragments/mcp-hostowany.feature.rst + +Serwis wystawia własny serwer MCP pod adresem ``/mcp`` (publiczny) oraz +``/mcp/auth`` (z logowaniem OAuth). Asystenci AI obsługujący zdalne serwery +MCP łączą się jednym adresem — bez instalowania czegokolwiek i bez podawania +adresu uczelni. Instrukcja podłączenia jest na stronie ``/mcp/``. +``` + +- [ ] **Step 4: Uruchom pełną suitę** + +```bash +uv run pytest src/mcp_server/ src/oauth_mcp/ src/api_v1/ -q +uv run ruff format . +uv run ruff check . +uv run pre-commit +``` + +Expected: wszystko zielone. **`pre-commit` bez argumentów.** + +- [ ] **Step 5: Commit** + +```bash +git add src/mcp_server/ src/bpp/newsfragments/ docs/ +git commit -m "feat(mcp_server): raportowanie błędów narzędzi, healthcheck, newsfragment" +``` + +--- + +## Self-review planu + +**Pokrycie specu.** §1 → Task 10 (strona z oboma adresami). §2 → kontekst, +nie kod. §4 D1 → Task 1+7; D2 → Task 2+6+8; D3 → Task 7; D4/D9/D12 → Task 4; +D5 → Task 3+6; D6 → Task 11; D7/D10 → Task 5+6; D8 → narzędzia są read-only +z pakietu; D11 → Task 4+6. §5 → Tasks 2–8. §6 → Task 9 + `_dozwolone_hosty` +w Task 7. §7 → Task 11 (+ §7.5 w Task 12). §8 → Task 10. §9 → Task 1. +§10 → Task 12 (healthcheck); ModSecurity i `limit_req` są w `bpp-deploy`, +poza tym repo — **do zgłoszenia przy wdrożeniu, odnotowane w §15 specu**. +§11 → Tasks 2–11. §12 → kryteria pokryte testami. + +**Placeholdery.** Brak — każdy krok ma kod albo dokładną komendę. + +**Spójność typów.** `StartMcp.zapewnij/wystartowany/zywy` używane w Task 6, 8 +i 12 zgodnie z definicją z Task 2. `DaneZadania(host, scheme, ip, bearer, +deadline)` konstruowane identycznie w Taskach 3, 4, 6, 11. `build_application()` +zwraca krotkę `(aplikacja, start)` — rozpakowywaną tak samo w Taskach 7 i 8. + +**Luka świadoma:** `limit_req` i ModSecurity wymagają zmian w repozytorium +`bpp-deploy`; nie da się ich wykonać w tym planie. Wchodzą jako osobne +zadanie wdrożeniowe po scaleniu. From a03c27ddc7559ade1bc9bd8ad31bae8cf601af8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sun, 6 Sep 2026 14:17:20 +0200 Subject: [PATCH 07/29] =?UTF-8?q?feat(mcp=5Fserver):=20szkielet=20aplikacj?= =?UTF-8?q?i=20+=20zale=C5=BCno=C5=9B=C4=87=20bpp-mcp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU --- pyproject.toml | 3 + src/django_bpp/settings/base.py | 1 + src/mcp_server/__init__.py | 6 + src/mcp_server/apps.py | 7 + src/mcp_server/tests/__init__.py | 0 src/mcp_server/tests/test_aplikacja.py | 13 + uv.lock | 1830 +++++++++++++++--------- 7 files changed, 1170 insertions(+), 690 deletions(-) create mode 100644 src/mcp_server/__init__.py create mode 100644 src/mcp_server/apps.py create mode 100644 src/mcp_server/tests/__init__.py create mode 100644 src/mcp_server/tests/test_aplikacja.py diff --git a/pyproject.toml b/pyproject.toml index d0b2fbb29..3fe1ece94 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,6 +82,9 @@ dependencies = [ "Pillow>=12.2,<14", "django-grappelli>=5.0.0,<6", "django-multiseek==0.10.3", + # Narzędzia MCP + szwy do hostowania ich w naszym procesie (spec §2.2). + # Ciągnie mcp>=2,<3, starlette i httpx — patrz spec §9. + "bpp-mcp>=0.4,<0.5", "django-braces>=1.15.0", "django-loginas==0.3.14", "django-webmaster-verification==0.4.3", diff --git a/src/django_bpp/settings/base.py b/src/django_bpp/settings/base.py index 2ca0c3958..030d8b6e6 100644 --- a/src/django_bpp/settings/base.py +++ b/src/django_bpp/settings/base.py @@ -478,6 +478,7 @@ def _elem_in_sys_argv(possible): "rest_framework", "oauth_mcp", "oauth2_provider", + "mcp_server", "django_filters", "api_v1", "adminsortable2", diff --git a/src/mcp_server/__init__.py b/src/mcp_server/__init__.py new file mode 100644 index 000000000..ae5873c15 --- /dev/null +++ b/src/mcp_server/__init__.py @@ -0,0 +1,6 @@ +"""Hostowany endpoint MCP serwisu BPP (/mcp, /mcp/auth). + +Narzędzia pochodzą z pakietu ``bpp-mcp``; ta aplikacja dostarcza wyłącznie +warstwę montażu: routing ASGI, kontekst żądania, uwierzytelnianie i klienta +wołającego własną aplikację Django w procesie. +""" diff --git a/src/mcp_server/apps.py b/src/mcp_server/apps.py new file mode 100644 index 000000000..d59ebad3c --- /dev/null +++ b/src/mcp_server/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class McpServerConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "mcp_server" + verbose_name = "Serwer MCP" diff --git a/src/mcp_server/tests/__init__.py b/src/mcp_server/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/mcp_server/tests/test_aplikacja.py b/src/mcp_server/tests/test_aplikacja.py new file mode 100644 index 000000000..d1a8845da --- /dev/null +++ b/src/mcp_server/tests/test_aplikacja.py @@ -0,0 +1,13 @@ +"""Aplikacja mcp_server jest zarejestrowana, a pakiet bpp-mcp dostępny.""" + +from django.apps import apps + + +def test_aplikacja_zarejestrowana(): + assert apps.is_installed("mcp_server") + + +def test_pakiet_bpp_mcp_dostepny(): + from bpp_mcp import register_tools + + assert callable(register_tools) diff --git a/uv.lock b/uv.lock index 450c567bd..64a8a78d9 100644 --- a/uv.lock +++ b/uv.lock @@ -52,21 +52,30 @@ name = "amqp" version = "5.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "vine" }, + { name = "vine", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/79/fc/ec94a357dfc6683d8c86f8b4cfa5416a4c36b28052ec8260c77aca96a443/amqp-5.3.1.tar.gz", hash = "sha256:cddc00c725449522023bad949f70fff7b48f0b1ade74d170a6f10ab044739432", size = 129013, upload-time = "2024-11-12T19:55:44.051Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/26/99/fc813cd978842c26c82534010ea849eee9ab3a13ea2b74e95cb9c99e747b/amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2", size = 50944, upload-time = "2024-11-12T19:55:41.782Z" }, ] +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + [[package]] name = "anyio" version = "4.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "idna" }, - { name = "sniffio" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "idna", marker = "platform_python_implementation != 'PyPy'" }, + { name = "sniffio", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13' and platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" } wheels = [ @@ -87,8 +96,8 @@ name = "arrow" version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "python-dateutil" }, - { name = "tzdata" }, + { name = "python-dateutil", marker = "platform_python_implementation != 'PyPy'" }, + { name = "tzdata", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b9/33/032cdc44182491aa708d06a68b62434140d8c50820a087fac7af37703357/arrow-1.4.0.tar.gz", hash = "sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7", size = 152931, upload-time = "2025-10-18T17:46:46.761Z" } wheels = [ @@ -145,10 +154,10 @@ name = "autobahn" version = "24.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography" }, - { name = "hyperlink" }, - { name = "setuptools" }, - { name = "txaio" }, + { name = "cryptography", marker = "platform_python_implementation != 'PyPy'" }, + { name = "hyperlink", marker = "platform_python_implementation != 'PyPy'" }, + { name = "setuptools", marker = "platform_python_implementation != 'PyPy'" }, + { name = "txaio", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/38/f2/8dffb3b709383ba5b47628b0cc4e43e8d12d59eecbddb62cfccac2e7cf6a/autobahn-24.4.2.tar.gz", hash = "sha256:a2d71ef1b0cf780b6d11f8b205fd2c7749765e65795f2ea7d823796642ee92c9", size = 482700, upload-time = "2024-08-02T09:26:48.241Z" } wheels = [ @@ -178,8 +187,8 @@ name = "azure-core" version = "1.41.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "requests" }, - { name = "typing-extensions" }, + { name = "requests", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a6/f3/b416179e408990df5db0d516283022dde0f5d0111d98c1a848e41853e81c/azure_core-1.41.0.tar.gz", hash = "sha256:f46ff5dfcd230f25cf1c19e8a34b8dc08a337b2503e268bb600a16c00db8ad5a", size = 381042, upload-time = "2026-05-07T23:30:54.302Z" } wheels = [ @@ -191,10 +200,10 @@ name = "azure-storage-blob" version = "12.29.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "azure-core" }, - { name = "cryptography" }, - { name = "isodate" }, - { name = "typing-extensions" }, + { name = "azure-core", marker = "platform_python_implementation != 'PyPy'" }, + { name = "cryptography", marker = "platform_python_implementation != 'PyPy'" }, + { name = "isodate", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/59/25/fdcf1e381922dbab8ba23d6fd78d397fe6cbac6b480310218834b7bc91fe/azure_storage_blob-12.29.0.tar.gz", hash = "sha256:2824ddd7ebc9056034ebc76b17971a38e9aa5835abb0d565b9700493f2a6c657", size = 611359, upload-time = "2026-05-15T03:34:59.865Z" } wheels = [ @@ -294,8 +303,8 @@ name = "beautifulsoup4" version = "4.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "soupsieve" }, - { name = "typing-extensions" }, + { name = "soupsieve", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" } wheels = [ @@ -307,7 +316,7 @@ name = "bibtexparser" version = "2.0.0b9" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pylatexenc" }, + { name = "pylatexenc", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f4/37/e21f79f5116ad1fb92a8eb0e8a005cad59eb24660003982840daa6b9fafd/bibtexparser-2.0.0b9.tar.gz", hash = "sha256:bcddf796cfe321bb92c17c844ca65d81731fb753a1dd0e82bc76a9947aa0f360", size = 40435, upload-time = "2026-01-29T19:47:52.605Z" } wheels = [ @@ -328,7 +337,7 @@ name = "bleach" version = "6.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "webencodings" }, + { name = "webencodings", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/48/3c/e12ac860709702bd5ebeb9b56a4fe334f1001246ee1b8f2b7ee28912df7d/bleach-6.4.0.tar.gz", hash = "sha256:4202482733d85cedd04e59fcb2f89f4e4c7c385a78d3c3c23c30446843a37452", size = 204857, upload-time = "2026-06-05T13:01:13.734Z" } wheels = [ @@ -340,9 +349,9 @@ name = "boto3" version = "1.43.22" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "botocore" }, - { name = "jmespath" }, - { name = "s3transfer" }, + { name = "botocore", marker = "platform_python_implementation != 'PyPy'" }, + { name = "jmespath", marker = "platform_python_implementation != 'PyPy'" }, + { name = "s3transfer", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/64/31/32388d5ec332ffe81d8f3650860f94b66294009172d188c390cad58c6f5f/boto3-1.43.22.tar.gz", hash = "sha256:2a7fe12d8e0731bb8aa7c1e59b4ccc770fda031b8659c2f6f497393bdcec3051", size = 113203, upload-time = "2026-06-03T19:33:13.39Z" } wheels = [ @@ -354,9 +363,9 @@ name = "botocore" version = "1.43.22" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "jmespath" }, - { name = "python-dateutil" }, - { name = "urllib3" }, + { name = "jmespath", marker = "platform_python_implementation != 'PyPy'" }, + { name = "python-dateutil", marker = "platform_python_implementation != 'PyPy'" }, + { name = "urllib3", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/76/cf/840f1b8db16d45e3807c23d1ea779723eed1cd9cf3b6c49e16f372d2a777/botocore-1.43.22.tar.gz", hash = "sha256:b00de525e538289ed4a7a85263f1be4e47473c124cec87be6b23be49356bf745", size = 15458781, upload-time = "2026-06-03T19:33:02.882Z" } wheels = [ @@ -368,186 +377,187 @@ name = "bpp-iplweb" version = "202608.1401" source = { editable = "." } dependencies = [ - { name = "arrow" }, - { name = "babel" }, - { name = "beautifulsoup4" }, - { name = "bibtexparser" }, - { name = "celery" }, - { name = "celery-singleton" }, - { name = "channels", extra = ["daphne"] }, - { name = "channels-redis" }, - { name = "chardet" }, - { name = "crispy-bootstrap3" }, - { name = "crispy-bootstrap5" }, - { name = "crispy-forms-foundation" }, - { name = "crossrefapi" }, - { name = "cryptography" }, - { name = "cssmin" }, - { name = "dbfread" }, - { name = "dj-pagination" }, - { name = "django" }, - { name = "django-admin-sortable2" }, - { name = "django-admin-tools" }, - { name = "django-altcha" }, - { name = "django-autocomplete-light" }, - { name = "django-autoslug" }, - { name = "django-axes" }, - { name = "django-braces" }, - { name = "django-cacheops" }, - { name = "django-celery-email" }, - { name = "django-celery-results" }, - { name = "django-channels-broadcast" }, - { name = "django-classy-tags" }, - { name = "django-columns" }, - { name = "django-compressor" }, - { name = "django-constance" }, - { name = "django-cookie-law" }, - { name = "django-countdown" }, - { name = "django-crispy-forms" }, - { name = "django-dbtemplates-iplweb" }, - { name = "django-denorm-iplweb" }, - { name = "django-dirtyfields" }, - { name = "django-dsl" }, - { name = "django-dynamic-admin-columns" }, - { name = "django-easy-audit" }, - { name = "django-environ" }, - { name = "django-extensions" }, - { name = "django-favicon-plus-reloaded" }, - { name = "django-filter" }, - { name = "django-first-run-wizard" }, - { name = "django-flexible-reports" }, - { name = "django-formdefaults" }, - { name = "django-formtools" }, - { name = "django-fsm-2" }, - { name = "django-grappelli" }, - { name = "django-group-by" }, - { name = "django-import-export" }, - { name = "django-liveops", extra = ["celery"] }, - { name = "django-loginas" }, - { name = "django-messages-extends" }, - { name = "django-minify-html" }, - { name = "django-model-utils" }, - { name = "django-mptt" }, - { name = "django-multiseek" }, - { name = "django-oauth-toolkit" }, - { name = "django-password-policies-iplweb" }, - { name = "django-pbn-client" }, - { name = "django-pg-baseline" }, - { name = "django-polish-inflection" }, - { name = "django-prometheus" }, - { name = "django-querysetsequence" }, - { name = "django-redis-iplweb" }, - { name = "django-reversion" }, - { name = "django-reversion-compare" }, - { name = "django-sendfile2" }, - { name = "django-session-security" }, - { name = "django-site-blog" }, - { name = "django-soft-delete" }, - { name = "django-static-sitemaps" }, - { name = "django-tables2" }, - { name = "django-tabular-permissions" }, - { name = "django-taggit" }, - { name = "django-templated-email" }, - { name = "django-tinymce" }, - { name = "django-weasyprint" }, - { name = "django-webmaster-verification" }, - { name = "djangoql-iplweb" }, - { name = "djangorestframework" }, - { name = "dspace-rest-client" }, - { name = "gunicorn" }, - { name = "isbnlib" }, - { name = "langdetect" }, - { name = "markdown" }, - { name = "moai-iplweb" }, - { name = "mozilla-django-oidc" }, - { name = "nh3" }, - { name = "numpy" }, - { name = "openpyxl" }, - { name = "ortools" }, - { name = "pandas" }, - { name = "pathspec" }, - { name = "pbn-client" }, - { name = "pillow" }, - { name = "psycopg2-binary" }, - { name = "pygad" }, - { name = "pymed-iplweb" }, - { name = "pypandoc" }, - { name = "python-dateutil" }, - { name = "python-docx" }, - { name = "questionary" }, - { name = "requests" }, - { name = "requests-oauthlib" }, - { name = "rjsmin" }, - { name = "rollbar" }, - { name = "simplejson" }, - { name = "sqlparse" }, - { name = "tablib" }, - { name = "thefuzz" }, - { name = "tqdm" }, - { name = "twisted", extra = ["http2", "tls"] }, - { name = "unidecode" }, - { name = "urllib3" }, - { name = "uvicorn", extra = ["standard"] }, - { name = "uvicorn-worker" }, - { name = "watchdog" }, - { name = "weasyprint" }, - { name = "wosclient" }, - { name = "xhtml2pdf" }, - { name = "xlrd" }, + { name = "arrow", marker = "platform_python_implementation != 'PyPy'" }, + { name = "babel", marker = "platform_python_implementation != 'PyPy'" }, + { name = "beautifulsoup4", marker = "platform_python_implementation != 'PyPy'" }, + { name = "bibtexparser", marker = "platform_python_implementation != 'PyPy'" }, + { name = "bpp-mcp", marker = "platform_python_implementation != 'PyPy'" }, + { name = "celery", marker = "platform_python_implementation != 'PyPy'" }, + { name = "celery-singleton", marker = "platform_python_implementation != 'PyPy'" }, + { name = "channels", extra = ["daphne"], marker = "platform_python_implementation != 'PyPy'" }, + { name = "channels-redis", marker = "platform_python_implementation != 'PyPy'" }, + { name = "chardet", marker = "platform_python_implementation != 'PyPy'" }, + { name = "crispy-bootstrap3", marker = "platform_python_implementation != 'PyPy'" }, + { name = "crispy-bootstrap5", marker = "platform_python_implementation != 'PyPy'" }, + { name = "crispy-forms-foundation", marker = "platform_python_implementation != 'PyPy'" }, + { name = "crossrefapi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "cryptography", marker = "platform_python_implementation != 'PyPy'" }, + { name = "cssmin", marker = "platform_python_implementation != 'PyPy'" }, + { name = "dbfread", marker = "platform_python_implementation != 'PyPy'" }, + { name = "dj-pagination", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-admin-sortable2", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-admin-tools", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-altcha", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-autocomplete-light", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-autoslug", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-axes", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-braces", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-cacheops", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-celery-email", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-celery-results", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-channels-broadcast", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-classy-tags", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-columns", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-compressor", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-constance", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-cookie-law", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-countdown", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-crispy-forms", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-dbtemplates-iplweb", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-denorm-iplweb", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-dirtyfields", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-dsl", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-dynamic-admin-columns", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-easy-audit", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-environ", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-extensions", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-favicon-plus-reloaded", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-filter", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-first-run-wizard", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-flexible-reports", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-formdefaults", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-formtools", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-fsm-2", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-grappelli", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-group-by", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-import-export", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-liveops", extra = ["celery"], marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-loginas", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-messages-extends", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-minify-html", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-model-utils", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-mptt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-multiseek", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-oauth-toolkit", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-password-policies-iplweb", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-pbn-client", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-pg-baseline", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-polish-inflection", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-prometheus", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-querysetsequence", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-redis-iplweb", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-reversion", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-reversion-compare", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-sendfile2", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-session-security", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-site-blog", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-soft-delete", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-static-sitemaps", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-tables2", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-tabular-permissions", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-taggit", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-templated-email", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-tinymce", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-weasyprint", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-webmaster-verification", marker = "platform_python_implementation != 'PyPy'" }, + { name = "djangoql-iplweb", marker = "platform_python_implementation != 'PyPy'" }, + { name = "djangorestframework", marker = "platform_python_implementation != 'PyPy'" }, + { name = "dspace-rest-client", marker = "platform_python_implementation != 'PyPy'" }, + { name = "gunicorn", marker = "platform_python_implementation != 'PyPy'" }, + { name = "isbnlib", marker = "platform_python_implementation != 'PyPy'" }, + { name = "langdetect", marker = "platform_python_implementation != 'PyPy'" }, + { name = "markdown", marker = "platform_python_implementation != 'PyPy'" }, + { name = "moai-iplweb", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mozilla-django-oidc", marker = "platform_python_implementation != 'PyPy'" }, + { name = "nh3", marker = "platform_python_implementation != 'PyPy'" }, + { name = "numpy", marker = "platform_python_implementation != 'PyPy'" }, + { name = "openpyxl", marker = "platform_python_implementation != 'PyPy'" }, + { name = "ortools", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pandas", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pathspec", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pbn-client", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pillow", marker = "platform_python_implementation != 'PyPy'" }, + { name = "psycopg2-binary", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pygad", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pymed-iplweb", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pypandoc", marker = "platform_python_implementation != 'PyPy'" }, + { name = "python-dateutil", marker = "platform_python_implementation != 'PyPy'" }, + { name = "python-docx", marker = "platform_python_implementation != 'PyPy'" }, + { name = "questionary", marker = "platform_python_implementation != 'PyPy'" }, + { name = "requests", marker = "platform_python_implementation != 'PyPy'" }, + { name = "requests-oauthlib", marker = "platform_python_implementation != 'PyPy'" }, + { name = "rjsmin", marker = "platform_python_implementation != 'PyPy'" }, + { name = "rollbar", marker = "platform_python_implementation != 'PyPy'" }, + { name = "simplejson", marker = "platform_python_implementation != 'PyPy'" }, + { name = "sqlparse", marker = "platform_python_implementation != 'PyPy'" }, + { name = "tablib", marker = "platform_python_implementation != 'PyPy'" }, + { name = "thefuzz", marker = "platform_python_implementation != 'PyPy'" }, + { name = "tqdm", marker = "platform_python_implementation != 'PyPy'" }, + { name = "twisted", extra = ["http2", "tls"], marker = "platform_python_implementation != 'PyPy'" }, + { name = "unidecode", marker = "platform_python_implementation != 'PyPy'" }, + { name = "urllib3", marker = "platform_python_implementation != 'PyPy'" }, + { name = "uvicorn", extra = ["standard"], marker = "platform_python_implementation != 'PyPy'" }, + { name = "uvicorn-worker", marker = "platform_python_implementation != 'PyPy'" }, + { name = "watchdog", marker = "platform_python_implementation != 'PyPy'" }, + { name = "weasyprint", marker = "platform_python_implementation != 'PyPy'" }, + { name = "wosclient", marker = "platform_python_implementation != 'PyPy'" }, + { name = "xhtml2pdf", marker = "platform_python_implementation != 'PyPy'" }, + { name = "xlrd", marker = "platform_python_implementation != 'PyPy'" }, ] [package.optional-dependencies] baseline-rebuild = [ - { name = "testcontainers" }, + { name = "testcontainers", marker = "platform_python_implementation != 'PyPy'" }, ] ldap = [ - { name = "django-auth-ldap" }, + { name = "django-auth-ldap", marker = "platform_python_implementation != 'PyPy'" }, ] office365 = [ - { name = "django-microsoft-auth" }, + { name = "django-microsoft-auth", marker = "platform_python_implementation != 'PyPy'" }, ] [package.dev-dependencies] dev = [ - { name = "bumpver" }, - { name = "django-debug-toolbar" }, - { name = "django-dev-helpers" }, - { name = "django-dynamic-fixture" }, - { name = "django-run-site" }, - { name = "django-webtest" }, - { name = "djlint" }, - { name = "ipdb" }, - { name = "ipython" }, - { name = "model-bakery" }, - { name = "nest-asyncio" }, - { name = "playwright" }, - { name = "pre-commit" }, - { name = "psutil" }, - { name = "pyparsing" }, - { name = "pytest" }, - { name = "pytest-cov" }, - { name = "pytest-django" }, - { name = "pytest-env" }, - { name = "pytest-httpserver" }, - { name = "pytest-mock" }, - { name = "pytest-playwright" }, - { name = "pytest-recording" }, - { name = "pytest-repeat" }, - { name = "pytest-rerunfailures" }, - { name = "pytest-split" }, - { name = "pytest-testcontainers" }, - { name = "pytest-testcontainers-django" }, - { name = "pytest-timeout" }, - { name = "pytest-tqdm" }, - { name = "pytest-xdist" }, - { name = "ruff" }, - { name = "testcontainers" }, - { name = "towncrier" }, - { name = "twine" }, - { name = "vcrpy" }, - { name = "vulture" }, - { name = "webtest" }, - { name = "werkzeug" }, + { name = "bumpver", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-debug-toolbar", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-dev-helpers", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-dynamic-fixture", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-run-site", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-webtest", marker = "platform_python_implementation != 'PyPy'" }, + { name = "djlint", marker = "platform_python_implementation != 'PyPy'" }, + { name = "ipdb", marker = "platform_python_implementation != 'PyPy'" }, + { name = "ipython", marker = "platform_python_implementation != 'PyPy'" }, + { name = "model-bakery", marker = "platform_python_implementation != 'PyPy'" }, + { name = "nest-asyncio", marker = "platform_python_implementation != 'PyPy'" }, + { name = "playwright", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pre-commit", marker = "platform_python_implementation != 'PyPy'" }, + { name = "psutil", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pyparsing", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pytest", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pytest-cov", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pytest-django", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pytest-env", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pytest-httpserver", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pytest-mock", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pytest-playwright", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pytest-recording", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pytest-repeat", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pytest-rerunfailures", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pytest-split", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pytest-testcontainers", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pytest-testcontainers-django", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pytest-timeout", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pytest-tqdm", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pytest-xdist", marker = "platform_python_implementation != 'PyPy'" }, + { name = "ruff", marker = "platform_python_implementation != 'PyPy'" }, + { name = "testcontainers", marker = "platform_python_implementation != 'PyPy'" }, + { name = "towncrier", marker = "platform_python_implementation != 'PyPy'" }, + { name = "twine", marker = "platform_python_implementation != 'PyPy'" }, + { name = "vcrpy", marker = "platform_python_implementation != 'PyPy'" }, + { name = "vulture", marker = "platform_python_implementation != 'PyPy'" }, + { name = "webtest", marker = "platform_python_implementation != 'PyPy'" }, + { name = "werkzeug", marker = "platform_python_implementation != 'PyPy'" }, ] [package.metadata] @@ -556,6 +566,7 @@ requires-dist = [ { name = "babel", specifier = ">=2.17" }, { name = "beautifulsoup4", specifier = ">=4.15.0" }, { name = "bibtexparser", specifier = ">=2.0.0b7" }, + { name = "bpp-mcp", specifier = ">=0.4,<0.5" }, { name = "celery", specifier = ">=5.6.3" }, { name = "celery-singleton", specifier = ">=0.3.1" }, { name = "channels", extras = ["daphne"], specifier = ">=4.3.2,<5" }, @@ -727,6 +738,20 @@ dev = [ { name = "werkzeug", specifier = ">=3.1.6" }, ] +[[package]] +name = "bpp-mcp" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mcp", marker = "platform_python_implementation != 'PyPy'" }, + { name = "starlette", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/82/eb56865df2ab216d71fd7ea932ae01083470f925414bf60c91f53dd50c77/bpp_mcp-0.4.0.tar.gz", hash = "sha256:fce4186bd736169789cd3fdd3f136cc5d6f1b8b88ec5acebdc0fc599a97a1281", size = 114594, upload-time = "2026-09-06T04:53:38.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/47/a67dfd205b77b4a127e32e4ee578ce611528bc0bbc00f19bd039c52a12dc/bpp_mcp-0.4.0-py3-none-any.whl", hash = "sha256:013daf032dc4e5480777c62c6aed4a7b81c2e4d58e4d2e6f3147c27de600ca72", size = 94069, upload-time = "2026-09-06T04:53:36.956Z" }, +] + [[package]] name = "brotli" version = "1.2.0" @@ -780,7 +805,7 @@ name = "brotlicffi" version = "1.1.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi" }, + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/95/9d/70caa61192f570fcf0352766331b735afa931b4c6bc9a348a0925cc13288/brotlicffi-1.1.0.0.tar.gz", hash = "sha256:b77827a689905143f87915310b93b273ab17888fd43ef350d4832c4a71083c13", size = 465192, upload-time = "2023-09-14T14:22:40.707Z" } wheels = [ @@ -797,10 +822,10 @@ name = "bumpver" version = "2026.1132" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click" }, - { name = "colorama" }, - { name = "lexid" }, - { name = "toml" }, + { name = "click", marker = "platform_python_implementation != 'PyPy'" }, + { name = "colorama", marker = "platform_python_implementation != 'PyPy'" }, + { name = "lexid", marker = "platform_python_implementation != 'PyPy'" }, + { name = "toml", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/25/22/c0c9bf9e7ad6877e4c7c3dedd464e390d9fadb8927c61d6d77547eb17539/bumpver-2026.1132.tar.gz", hash = "sha256:80b223c23fca9bc9dd569b7a44680949d34bee23a738860d9a9b36f1abe3b0e0", size = 116784, upload-time = "2026-05-22T18:40:32.333Z" } wheels = [ @@ -812,15 +837,15 @@ name = "celery" version = "5.6.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "billiard" }, - { name = "click" }, - { name = "click-didyoumean" }, - { name = "click-plugins" }, - { name = "click-repl" }, - { name = "kombu" }, - { name = "python-dateutil" }, - { name = "tzlocal" }, - { name = "vine" }, + { name = "billiard", marker = "platform_python_implementation != 'PyPy'" }, + { name = "click", marker = "platform_python_implementation != 'PyPy'" }, + { name = "click-didyoumean", marker = "platform_python_implementation != 'PyPy'" }, + { name = "click-plugins", marker = "platform_python_implementation != 'PyPy'" }, + { name = "click-repl", marker = "platform_python_implementation != 'PyPy'" }, + { name = "kombu", marker = "platform_python_implementation != 'PyPy'" }, + { name = "python-dateutil", marker = "platform_python_implementation != 'PyPy'" }, + { name = "tzlocal", marker = "platform_python_implementation != 'PyPy'" }, + { name = "vine", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e8/b4/a1233943ab5c8ea05fb877a88a0a0622bf47444b99e4991a8045ac37ea1d/celery-5.6.3.tar.gz", hash = "sha256:177006bd2054b882e9f01be59abd8529e88879ef50d7918a7050c5a9f4e12912", size = 1742243, upload-time = "2026-03-26T12:14:51.76Z" } wheels = [ @@ -832,8 +857,8 @@ name = "celery-singleton" version = "0.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "celery" }, - { name = "redis" }, + { name = "celery", marker = "platform_python_implementation != 'PyPy'" }, + { name = "redis", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/23/f0/ef4c769215086812a9e968bf9309fbd90bfe663d5175742e9386e1e1e052/celery-singleton-0.3.1.tar.gz", hash = "sha256:260ce4978e631f8682ea0ccb03d7f3b87d42bc20e04e9bd46ddb78a2f8035d1e", size = 4659, upload-time = "2021-01-14T16:23:40.801Z" } wheels = [ @@ -854,7 +879,7 @@ name = "cffi" version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pycparser", marker = "implementation_name != 'PyPy'" }, + { name = "pycparser", marker = "implementation_name != 'PyPy' and platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } wheels = [ @@ -933,8 +958,8 @@ name = "channels" version = "4.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "asgiref" }, - { name = "django" }, + { name = "asgiref", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/74/92/b18d4bb54d14986a8b35215a1c9e6a7f9f4d57ca63ac9aee8290ebb4957d/channels-4.3.2.tar.gz", hash = "sha256:f2bb6bfb73ad7fb4705041d07613c7b4e69528f01ef8cb9fb6c21d9295f15667", size = 27023, upload-time = "2025-11-20T15:13:05.102Z" } wheels = [ @@ -943,7 +968,7 @@ wheels = [ [package.optional-dependencies] daphne = [ - { name = "daphne" }, + { name = "daphne", marker = "platform_python_implementation != 'PyPy'" }, ] [[package]] @@ -951,10 +976,10 @@ name = "channels-redis" version = "4.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "asgiref" }, - { name = "channels" }, - { name = "msgpack" }, - { name = "redis" }, + { name = "asgiref", marker = "platform_python_implementation != 'PyPy'" }, + { name = "channels", marker = "platform_python_implementation != 'PyPy'" }, + { name = "msgpack", marker = "platform_python_implementation != 'PyPy'" }, + { name = "redis", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ab/69/fd3407ad407a80e72ca53850eb7a4c306273e67d5bbb71a86d0e6d088439/channels_redis-4.3.0.tar.gz", hash = "sha256:740ee7b54f0e28cf2264a940a24453d3f00526a96931f911fcb69228ef245dd2", size = 31440, upload-time = "2025-07-22T13:48:46.087Z" } wheels = [ @@ -1076,7 +1101,7 @@ name = "click" version = "8.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "colorama", marker = "platform_python_implementation != 'PyPy' and sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } wheels = [ @@ -1088,7 +1113,7 @@ name = "click-didyoumean" version = "0.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click" }, + { name = "click", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/30/ce/217289b77c590ea1e7c24242d9ddd6e249e52c795ff10fac2c50062c48cb/click_didyoumean-0.3.1.tar.gz", hash = "sha256:4f82fdff0dbe64ef8ab2279bd6aa3f6a99c3b28c05aa09cbfc07c9d7fbb5a463", size = 3089, upload-time = "2024-03-24T08:22:07.499Z" } wheels = [ @@ -1100,7 +1125,7 @@ name = "click-plugins" version = "1.1.1.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click" }, + { name = "click", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c3/a4/34847b59150da33690a36da3681d6bbc2ec14ee9a846bc30a6746e5984e4/click_plugins-1.1.1.2.tar.gz", hash = "sha256:d7af3984a99d243c131aa1a828331e7630f4a88a9741fd05c927b204bcf92261", size = 8343, upload-time = "2025-06-25T00:47:37.555Z" } wheels = [ @@ -1112,8 +1137,8 @@ name = "click-repl" version = "0.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click" }, - { name = "prompt-toolkit" }, + { name = "click", marker = "platform_python_implementation != 'PyPy'" }, + { name = "prompt-toolkit", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cb/a2/57f4ac79838cfae6912f997b4d1a64a858fb0c86d7fcaae6f7b58d267fca/click-repl-0.3.0.tar.gz", hash = "sha256:17849c23dba3d667247dc4defe1757fff98694e90fe37474f3feebb69ced26a9", size = 10449, upload-time = "2023-06-15T12:43:51.141Z" } wheels = [ @@ -1248,7 +1273,7 @@ wheels = [ [package.optional-dependencies] toml = [ - { name = "tomli", marker = "python_full_version <= '3.11'" }, + { name = "tomli", marker = "python_full_version <= '3.11' and platform_python_implementation != 'PyPy'" }, ] [[package]] @@ -1256,8 +1281,8 @@ name = "crispy-bootstrap3" version = "2024.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django" }, - { name = "django-crispy-forms" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-crispy-forms", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d1/80/ca6242629ea7405b4d6deb206f94dc3e2bff355ba139e94827f3be944eff/crispy-bootstrap3-2024.1.tar.gz", hash = "sha256:343c696ae1a854ac0ccad25e9e7d782400783034220a11aa179d1d799acf6161", size = 29205, upload-time = "2024-01-13T09:04:54.782Z" } wheels = [ @@ -1269,8 +1294,8 @@ name = "crispy-bootstrap5" version = "2026.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django" }, - { name = "django-crispy-forms" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-crispy-forms", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ab/e8/05e1170f6b8fbfe4098392bdca813c2094659853a438919234d4663009b1/crispy_bootstrap5-2026.3.tar.gz", hash = "sha256:e7f5adb36acfbb456444c46e82c436931c796c539e9c620be4fa9dc9c9d6679c", size = 23452, upload-time = "2026-03-01T10:08:00.459Z" } wheels = [ @@ -1282,8 +1307,8 @@ name = "crispy-forms-foundation" version = "1.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django" }, - { name = "django-crispy-forms" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-crispy-forms", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/46/a3/baf46ed6d9a160444f50e579892bdfab97375142eb1dbec3ffcd49305079/crispy_forms_foundation-1.1.0.tar.gz", hash = "sha256:5f4544787277599e281964c0a5ffdac1d606613bd7bf4ce50a60554dd9eb3100", size = 21214, upload-time = "2025-07-16T01:09:39.086Z" } @@ -1292,9 +1317,9 @@ name = "crossrefapi" version = "1.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ipython" }, - { name = "requests" }, - { name = "urllib3" }, + { name = "ipython", marker = "platform_python_implementation != 'PyPy'" }, + { name = "requests", marker = "platform_python_implementation != 'PyPy'" }, + { name = "urllib3", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f7/c7/bd020af76301bfede41869b3c18f3efb6fac7dd064371d2a9956bc3306c4/crossrefapi-1.7.0.tar.gz", hash = "sha256:598f27a3cc1bd8d29770de284b0b1315c820de1d70894084945d5265e6fe2dae", size = 13444, upload-time = "2025-07-03T20:39:49.223Z" } wheels = [ @@ -1306,7 +1331,7 @@ name = "cryptography" version = "50.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi" }, + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } wheels = [ @@ -1362,9 +1387,9 @@ name = "cssbeautifier" version = "1.15.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "editorconfig" }, - { name = "jsbeautifier" }, - { name = "six" }, + { name = "editorconfig", marker = "platform_python_implementation != 'PyPy'" }, + { name = "jsbeautifier", marker = "platform_python_implementation != 'PyPy'" }, + { name = "six", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f7/01/fdf41c1e5f93d359681976ba10410a04b299d248e28ecce1d4e88588dde4/cssbeautifier-1.15.4.tar.gz", hash = "sha256:9bb08dc3f64c101a01677f128acf01905914cf406baf87434dcde05b74c0acf5", size = 25376, upload-time = "2025-02-27T17:53:51.341Z" } wheels = [ @@ -1382,8 +1407,8 @@ name = "cssselect2" version = "0.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "tinycss2" }, - { name = "webencodings" }, + { name = "tinycss2", marker = "platform_python_implementation != 'PyPy'" }, + { name = "webencodings", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9f/86/fd7f58fc498b3166f3a7e8e0cddb6e620fe1da35b02248b1bd59e95dbaaa/cssselect2-0.8.0.tar.gz", hash = "sha256:7674ffb954a3b46162392aee2a3a0aedb2e14ecf99fcc28644900f4e6e3e9d3a", size = 35716, upload-time = "2025-03-05T14:46:07.988Z" } wheels = [ @@ -1395,9 +1420,9 @@ name = "daphne" version = "4.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "asgiref" }, - { name = "autobahn" }, - { name = "twisted", extra = ["tls"] }, + { name = "asgiref", marker = "platform_python_implementation != 'PyPy'" }, + { name = "autobahn", marker = "platform_python_implementation != 'PyPy'" }, + { name = "twisted", extra = ["tls"], marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/64/d3/65ff32c01cc64d44441b038dbb7cfb0c6a5507a1c937b3d41bd99af7bdc4/daphne-4.2.2.tar.gz", hash = "sha256:6c3527d4ce32630ae054dfb0ef5578e9a35d2f39f0ebcd02ef4f9129a121ce8d", size = 47601, upload-time = "2026-06-03T10:53:13.31Z" } wheels = [ @@ -1454,9 +1479,9 @@ name = "django" version = "5.2.17" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "asgiref" }, - { name = "sqlparse" }, - { name = "tzdata", marker = "sys_platform == 'win32'" }, + { name = "asgiref", marker = "platform_python_implementation != 'PyPy'" }, + { name = "sqlparse", marker = "platform_python_implementation != 'PyPy'" }, + { name = "tzdata", marker = "platform_python_implementation != 'PyPy' and sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d5/d8/43e9d000519adceb189620b6869ff88031e046df91c2e9da72f8f6918399/django-5.2.17.tar.gz", hash = "sha256:9d4d93be539a18ab80d058eb515900e10951e04c537c5a6b394fc49528d3251f", size = 10889740, upload-time = "2026-08-04T15:04:03.173Z" } wheels = [ @@ -1468,7 +1493,7 @@ name = "django-admin-sortable2" version = "2.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e2/09/cc2d19412e47aaf627f54f9b5b544de73553bd71e25d417dd62e91b36edd/django_admin_sortable2-2.3.1.tar.gz", hash = "sha256:e397d08606f9e0da73e52661b841021ecab21be6f74783408e625ccddfe975af", size = 67497, upload-time = "2026-01-22T14:20:29.049Z" } wheels = [ @@ -1489,8 +1514,8 @@ name = "django-altcha" version = "1.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "altcha" }, - { name = "django" }, + { name = "altcha", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a9/7d/c77f7a0b6aac8d08d27c0e7de98217c37413bdc641c496c228076875a645/django_altcha-1.0.0.tar.gz", hash = "sha256:06a4f437b086a0c296b53e02dbd7f783ce07972150b8238dbae6da8cfb284efa", size = 61790, upload-time = "2026-04-21T10:40:57.301Z" } wheels = [ @@ -1502,7 +1527,7 @@ name = "django-appconf" version = "1.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/61/a9/dcf95ff3fa0620b6818fc02276fbbb8926e7f286039b6d015e56e8b7af39/django-appconf-1.1.0.tar.gz", hash = "sha256:9fcead372f82a0f21ee189434e7ae9c007cbb29af1118c18251720f3d06243e4", size = 15986, upload-time = "2025-02-13T16:09:40.258Z" } wheels = [ @@ -1514,8 +1539,8 @@ name = "django-auth-ldap" version = "5.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django" }, - { name = "python-ldap" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, + { name = "python-ldap", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a6/6d/d3ceb4b49e7153811a4b2d92bbe198a5ef2e2820469add3d6dc129ef2fab/django_auth_ldap-5.3.0.tar.gz", hash = "sha256:743d8107b146240b46f7e97207dc06cb11facc0cd70dce490b7ca09dd5643d19", size = 55272, upload-time = "2025-12-26T15:00:14.272Z" } wheels = [ @@ -1527,7 +1552,7 @@ name = "django-autocomplete-light" version = "5.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5d/ca/1288c2c7597484340c6ba5e3911c84b552511310633954d7aa496610e45a/django_autocomplete_light-5.0.0.tar.gz", hash = "sha256:115ee3541ee4a7ddd1b92396ea75d0f38d0b91899b0dc17259310975c728b32e", size = 117640, upload-time = "2026-06-18T01:43:28.909Z" } wheels = [ @@ -1548,8 +1573,8 @@ name = "django-axes" version = "8.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "asgiref" }, - { name = "django" }, + { name = "asgiref", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f0/b4/00c701a22d46288e3eb1340a1c141f5b4002572ce836a3e8d069f1e1de8a/django_axes-8.3.1.tar.gz", hash = "sha256:d57e923c0ba33cf45275253dd1313c3a3ff04bec686b38d677beb2b3d43c7bcd", size = 254715, upload-time = "2026-02-11T20:19:52.172Z" } wheels = [ @@ -1561,7 +1586,7 @@ name = "django-braces" version = "1.17.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/8a/c0/eb3e27e817eb4ba39d9c66fbd53139cb4db7073f5e6e71c28ce4d8b706da/django_braces-1.17.0.tar.gz", hash = "sha256:3a6fe630d3d91fef0711bfe1088d14af00c245559f79f6298040ed2b2f757337", size = 54227, upload-time = "2025-03-18T02:03:02.354Z" } wheels = [ @@ -1573,9 +1598,9 @@ name = "django-cacheops" version = "7.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django" }, - { name = "funcy" }, - { name = "redis" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, + { name = "funcy", marker = "platform_python_implementation != 'PyPy'" }, + { name = "redis", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1b/92/9df8a60ee959c214705ec3d3ccf80820097672a0260b149cf2751e818086/django_cacheops-7.2.tar.gz", hash = "sha256:cbc11cc0321295a3644e27bcb26940f08cb9c2e71d3ee506cbcff0bdda1a9f33", size = 72751, upload-time = "2025-04-20T04:36:19.068Z" } wheels = [ @@ -1587,9 +1612,9 @@ name = "django-celery-email" version = "3.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "celery" }, - { name = "django" }, - { name = "django-appconf" }, + { name = "celery", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-appconf", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/48/78/95c9959978c1b8abd587735cf084065b0ac8196580f6f30b2c9e9636401a/django-celery-email-3.0.0.tar.gz", hash = "sha256:5546cbba80952cc3b8a0ffa4206ce90a4a996a7ffd1c385a2bdb65903ca18ece", size = 12644, upload-time = "2019-12-11T15:32:11.6Z" } wheels = [ @@ -1601,8 +1626,8 @@ name = "django-celery-results" version = "2.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "celery" }, - { name = "django" }, + { name = "celery", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a6/b5/9966c28e31014c228305e09d48b19b35522a8f941fe5af5f81f40dc8fa80/django_celery_results-2.6.0.tar.gz", hash = "sha256:9abcd836ae6b61063779244d8887a88fe80bbfaba143df36d3cb07034671277c", size = 83985, upload-time = "2025-04-10T08:23:52.677Z" } wheels = [ @@ -1614,10 +1639,10 @@ name = "django-channels-broadcast" version = "0.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "asgiref" }, - { name = "channels" }, - { name = "django" }, - { name = "nest-asyncio" }, + { name = "asgiref", marker = "platform_python_implementation != 'PyPy'" }, + { name = "channels", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, + { name = "nest-asyncio", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/e0/facb33741759eb54efb5fcff1ca83ead25aece1fa2725b6c0fd6abb6835a/django_channels_broadcast-0.3.0.tar.gz", hash = "sha256:34492908b917bfb01e453def1d07de15ffd7e96f9141b4e4a73fd6ab7d7b63d7", size = 73467, upload-time = "2026-08-07T09:05:59.412Z" } wheels = [ @@ -1629,7 +1654,7 @@ name = "django-classy-tags" version = "4.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/91/7f/18da9d8fcc469fc7aa74abc526aacaa3cc7180ad2208f80d43e8b670aed6/django-classy-tags-4.1.0.tar.gz", hash = "sha256:c8d9d1aa2fa6e71c4d866df4dd11d23a69b8d25bbb750b2490a17b161774ee59", size = 24692, upload-time = "2023-07-29T09:06:18.646Z" } wheels = [ @@ -1641,7 +1666,7 @@ name = "django-columns" version = "0.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ac/5a/5178d6f6d070e91877b163a21f9990b57d62c9f146a41c8e3fcf0493e5b5/django-columns-0.1.0.tar.gz", hash = "sha256:cd5ffb9b1e31908dba058344fc0e7def2367f13b6fa4c9a6b801e29fc864fdf9", size = 5621, upload-time = "2014-05-12T20:50:19.316Z" } @@ -1650,10 +1675,10 @@ name = "django-compressor" version = "4.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django" }, - { name = "django-appconf" }, - { name = "rcssmin" }, - { name = "rjsmin" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-appconf", marker = "platform_python_implementation != 'PyPy'" }, + { name = "rcssmin", marker = "platform_python_implementation != 'PyPy'" }, + { name = "rjsmin", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a2/e4/c6d87b1341d744ceafa85eeceb2adabb1c62b795b8207cbc580fb70df8f4/django_compressor-4.6.0.tar.gz", hash = "sha256:c7478feab98f3368780591f9ee28a433350f5277dd28811f7f710f5bc6dff3c0", size = 99735, upload-time = "2025-11-10T13:12:11.439Z" } wheels = [ @@ -1674,8 +1699,8 @@ name = "django-cookie-law" version = "2.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django" }, - { name = "django-classy-tags" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-classy-tags", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f8/9b/ff89a562a6662a3cb58e73cdcc4cd110701eb0d77611487e8250b0d2a77e/django-cookie-law-2.2.0.tar.gz", hash = "sha256:c3c6c10a01b257886077dd170ead11a49448b5bdfd06e81acc5c7c4e2292d4c8", size = 16605, upload-time = "2022-03-11T10:13:38.693Z" } wheels = [ @@ -1687,7 +1712,7 @@ name = "django-countdown" version = "0.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ba/dd/6df46b3a8a160d8198ccc934acc510e5ec5a0367bab4a0a68efc3c80bb81/django_countdown-0.4.0.tar.gz", hash = "sha256:fc9e4d311ce2a08324ed3588d10f7bc423424f4a5581e8f826dee341c73a5f55", size = 63524, upload-time = "2026-08-25T13:57:29.4Z" } wheels = [ @@ -1699,7 +1724,7 @@ name = "django-crispy-forms" version = "2.7" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/bc/f5/b79e3ed7cae871d5d71bf3448d627e1fabab769358bb90808bec056556fe/django_crispy_forms-2.7.tar.gz", hash = "sha256:4c59bed60417375cba26cebb2c67ab350b655934670270b1c89dbcd7e60f1b4c", size = 1097842, upload-time = "2026-07-29T11:12:12.081Z" } wheels = [ @@ -1711,7 +1736,7 @@ name = "django-dbtemplates-iplweb" version = "4.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django-appconf" }, + { name = "django-appconf", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fa/02/3940dd656449f6e39d903857f41373d96725e18f567d85fd9c0df95f7d62/django_dbtemplates_iplweb-4.4.1.tar.gz", hash = "sha256:e6c88244b33ee4108e8c451c0208bd3a2b7ab47c1191f154e99cc298e6d254b1", size = 85494, upload-time = "2026-08-07T08:28:29.297Z" } wheels = [ @@ -1723,8 +1748,8 @@ name = "django-debug-toolbar" version = "7.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django" }, - { name = "sqlparse" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, + { name = "sqlparse", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/00/da/df67a8e407ab6141affee4ee5cb2704403b9d5465cc35abbb42f5190a3fa/django_debug_toolbar-7.1.1.tar.gz", hash = "sha256:2fb4093d95e6f87258606c55f4d2aa45d1847b17a415525a9dceac02e937a836", size = 363531, upload-time = "2026-08-14T13:18:47.219Z" } wheels = [ @@ -1736,10 +1761,10 @@ name = "django-denorm-iplweb" version = "1.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "celery" }, - { name = "celery-singleton" }, - { name = "django" }, - { name = "tqdm" }, + { name = "celery", marker = "platform_python_implementation != 'PyPy'" }, + { name = "celery-singleton", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, + { name = "tqdm", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ad/46/4df14edc899092155a9aa56a737ab25a541acbd98675cd4ba96071278d2e/django_denorm_iplweb-1.14.0.tar.gz", hash = "sha256:16f7b06753a74853176b9f167575a00f2663abbc7eca408e91d8488cd2a7c9b5", size = 99770, upload-time = "2026-08-07T09:30:56.733Z" } wheels = [ @@ -1751,7 +1776,7 @@ name = "django-dev-helpers" version = "0.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/4b/e1/98d637a5f0de8e2b53ffd30b71894dc57f6edf4ef6f0c977ec735158d9d6/django_dev_helpers-0.2.0.tar.gz", hash = "sha256:98756fdcaac9a41b4eda96507ffb1a64790b82d213a1b42a342139e845911200", size = 119496, upload-time = "2026-08-07T08:33:59.356Z" } wheels = [ @@ -1763,7 +1788,7 @@ name = "django-dirtyfields" version = "1.9.9" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/8e/0d/0c7d727f7f14f82450b72e5213c6ed0da3d16add2de2e59d46aea6b6eaa2/django_dirtyfields-1.9.9.tar.gz", hash = "sha256:c11c8e03827166d2c91f6634a4d10f5aa5cc7ea430d60e5bd576b3ebf319172f", size = 22209, upload-time = "2026-01-22T13:48:58.777Z" } wheels = [ @@ -1775,8 +1800,8 @@ name = "django-dsl" version = "0.1.15" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django" }, - { name = "ply" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, + { name = "ply", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/e8/3b7e7963c23dfd00e9d882af9a29c229156afc6bd98d37230411af8155c8/django_dsl-0.1.15.tar.gz", hash = "sha256:e7a6ca40ea48ea566a76c64b972a1b64f1611aebb7397823ab5f6e6cf4971a0b", size = 8567, upload-time = "2026-08-07T08:18:51.261Z" } wheels = [ @@ -1788,8 +1813,8 @@ name = "django-dynamic-admin-columns" version = "0.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django" }, - { name = "django-admin-sortable2" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-admin-sortable2", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fa/00/a4e9671c1c75b8f6abf5f7f7fc86fa82a4342b7a2d863c3e12cea31f9756/django_dynamic_admin_columns-0.6.0.tar.gz", hash = "sha256:c9ca73ef40ba5be6936181fed2ecd73300dd028134e65017409aee38165f5538", size = 52503, upload-time = "2026-08-07T08:33:40.235Z" } wheels = [ @@ -1810,7 +1835,7 @@ name = "django-easy-audit" version = "1.3.9" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9f/3e/ad1b812e580fabe91af7f8c4897b4fe6c4ede696f829a0152e24abd156da/django_easy_audit-1.3.9.tar.gz", hash = "sha256:931b9b6c8411e2b49e980989cbfd4dfd9fd3ea3b350846f9b92686bf7d275f8f", size = 41852, upload-time = "2026-06-30T03:07:30.952Z" } wheels = [ @@ -1831,7 +1856,7 @@ name = "django-extensions" version = "4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6d/b3/ed0f54ed706ec0b54fd251cc0364a249c6cd6c6ec97f04dc34be5e929eac/django_extensions-4.1.tar.gz", hash = "sha256:7b70a4d28e9b840f44694e3f7feb54f55d495f8b3fa6c5c0e5e12bcb2aa3cdeb", size = 283078, upload-time = "2025-04-11T01:15:39.617Z" } wheels = [ @@ -1843,8 +1868,8 @@ name = "django-favicon-plus-reloaded" version = "1.2.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django" }, - { name = "pillow" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pillow", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/35/0d/86d1fb1eebbbadeacf4d420cee9206b737c77609e01c93aab0cec7d344f3/django_favicon_plus_reloaded-1.2.4.tar.gz", hash = "sha256:e8363eb783e18c0ab02b8f1e2705d8648e01933bebda1f89cb7e9b00964e2380", size = 8560, upload-time = "2026-02-11T19:11:05.885Z" } wheels = [ @@ -1856,7 +1881,7 @@ name = "django-filter" version = "26.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cb/3e/563965173d4cbb5fc308087e7b3d11a115b7b67273d093622480b1e31f78/django_filter-26.1.tar.gz", hash = "sha256:66ea04031b068c77c86e1ac26ced7a3f8f13ce797f5795751707e3deefc58054", size = 144299, upload-time = "2026-07-11T09:27:02.767Z" } wheels = [ @@ -1868,7 +1893,7 @@ name = "django-first-run-wizard" version = "0.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/68/a4/9c87cd899e7cc9843f7280d1f52afafdd3e86d135a4d9461d7b59a5aca45/django_first_run_wizard-0.2.1.tar.gz", hash = "sha256:84eec6363ca6bd35f9df129dc1702ea9a1915f8ef2f1304c31e08e678d35d321", size = 27833, upload-time = "2026-08-07T08:17:01.189Z" } wheels = [ @@ -1880,14 +1905,14 @@ name = "django-flexible-reports" version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "bleach" }, - { name = "django" }, - { name = "django-dsl" }, - { name = "django-tables2" }, - { name = "djangoql-iplweb" }, - { name = "lxml" }, - { name = "pypandoc" }, - { name = "tablib" }, + { name = "bleach", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-dsl", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-tables2", marker = "platform_python_implementation != 'PyPy'" }, + { name = "djangoql-iplweb", marker = "platform_python_implementation != 'PyPy'" }, + { name = "lxml", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pypandoc", marker = "platform_python_implementation != 'PyPy'" }, + { name = "tablib", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/2f/77/17615f52ea5f5c046fa0581f0cbecccc4d0d146330b676b702d209922466/django_flexible_reports-0.4.2.tar.gz", hash = "sha256:20f63f3442d29ff14ec6a608a2fbadcbc838ce3fb63d6c4ba5b78f20199ac3ba", size = 34824, upload-time = "2026-08-07T08:21:41.137Z" } wheels = [ @@ -1899,7 +1924,7 @@ name = "django-formdefaults" version = "0.6.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cf/af/a6b6c4419a5f2d2710b02f519ad2f7292450ec40d09063db54158e49fd3e/django_formdefaults-0.6.3.tar.gz", hash = "sha256:9d3b4386c3e6aead1c3801ca4ffa518c9d6f143c17c5e306be745c6f2c876b8c", size = 28578, upload-time = "2026-08-07T08:32:32.722Z" } wheels = [ @@ -1911,7 +1936,7 @@ name = "django-formtools" version = "2.7" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/40/fe/48cf5d38070577b83288f2bfd16a85aa864213d27d7dc7e8ec0feaa0a8ce/django_formtools-2.7.tar.gz", hash = "sha256:52b30c08571108023937e4b5d96a5cc35282ff0a2e51630aeea00e27d723d3ac", size = 92473, upload-time = "2026-07-09T16:06:34.341Z" } wheels = [ @@ -1923,9 +1948,9 @@ name = "django-fsm-2" version = "4.2.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django" }, - { name = "django-stubs-ext" }, - { name = "typing-extensions", marker = "python_full_version < '3.12'" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-stubs-ext", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.12' and platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fa/eb/594712952364f0b1f009b9c414940a09031d07fe95bb888e92f50aa9a550/django_fsm_2-4.2.4.tar.gz", hash = "sha256:0e395afd03ff504afd476e8568ca213aafc3324bd4e5427e5b3287f7b1e7d979", size = 21544, upload-time = "2026-03-16T20:33:33.476Z" } wheels = [ @@ -1955,9 +1980,9 @@ name = "django-import-export" version = "4.4.2.dev0+iplweb" source = { git = "https://github.com/mpasternak/django-import-export.git?rev=d6ee0d39194fee31437affbdc6fee5ce549b4b8f#d6ee0d39194fee31437affbdc6fee5ce549b4b8f" } dependencies = [ - { name = "diff-match-patch" }, - { name = "django" }, - { name = "tablib" }, + { name = "diff-match-patch", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, + { name = "tablib", marker = "platform_python_implementation != 'PyPy'" }, ] [[package]] @@ -1965,7 +1990,7 @@ name = "django-js-asset" version = "3.1.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e6/91/c63f136f553ec24fc46ccf20ac7292a8df04815b383975b6f3f7f0060217/django_js_asset-3.1.2.tar.gz", hash = "sha256:1fc7584199ed1941ed7c8e7b87ca5524bb0f2ba941561d2a104e88ee9f07bedd", size = 9471, upload-time = "2025-03-04T15:22:49.789Z" } wheels = [ @@ -1977,9 +2002,9 @@ name = "django-liveops" version = "0.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "channels" }, - { name = "django" }, - { name = "django-channels-broadcast" }, + { name = "channels", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-channels-broadcast", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/78/ee/ee27891ea2e81d44b3d100b3b2b03b6fee2775bfe895bd98fd48d75fb7d0/django_liveops-0.4.1.tar.gz", hash = "sha256:2c6903881f26f287ce642503726172a650f846ab3b031879ed28655fdd6a2aa9", size = 279310, upload-time = "2026-08-07T08:29:30.625Z" } wheels = [ @@ -1988,7 +2013,7 @@ wheels = [ [package.optional-dependencies] celery = [ - { name = "celery" }, + { name = "celery", marker = "platform_python_implementation != 'PyPy'" }, ] [[package]] @@ -2014,10 +2039,10 @@ name = "django-microsoft-auth" version = "3.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django" }, - { name = "pyjwt", extra = ["crypto"] }, - { name = "requests" }, - { name = "requests-oauthlib" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pyjwt", extra = ["crypto"], marker = "platform_python_implementation != 'PyPy'" }, + { name = "requests", marker = "platform_python_implementation != 'PyPy'" }, + { name = "requests-oauthlib", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/12/de/549f316f6a463a7f93e1f72cd8f1b186b821ced4aa4b89bf6421c243529b/django_microsoft_auth-3.0.1.tar.gz", hash = "sha256:e149b35e8061e12f060b4fc31a6bea1e0cb32047801a076aeb972d68f1f97655", size = 52289, upload-time = "2022-10-28T16:59:07.573Z" } wheels = [ @@ -2029,9 +2054,9 @@ name = "django-minify-html" version = "1.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "asgiref" }, - { name = "django" }, - { name = "minify-html" }, + { name = "asgiref", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, + { name = "minify-html", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6f/aa/28cc8441ebe49d96b4c6727eb834ec949ac23865f56297b9cef54032ba75/django_minify_html-1.14.0.tar.gz", hash = "sha256:43c1c31c353ed40acfed4ddc15663bc6892c7745b09172e767dc944577e6241a", size = 10540, upload-time = "2025-10-27T14:44:31.259Z" } wheels = [ @@ -2043,7 +2068,7 @@ name = "django-model-utils" version = "5.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/81/60/5e232c32a2c977cc1af8c70a38ef436598bc649ad89c2c4568454edde2c9/django_model_utils-5.0.0.tar.gz", hash = "sha256:041cdd6230d2fbf6cd943e1969318bce762272077f4ecd333ab2263924b4e5eb", size = 80559, upload-time = "2024-09-04T11:35:22.858Z" } wheels = [ @@ -2055,7 +2080,7 @@ name = "django-mptt" version = "0.18.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django-js-asset" }, + { name = "django-js-asset", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/eb/25/04bb8e4384f3484dabbd0e2a84946f1c972b95b07512509a17aaa361be0f/django_mptt-0.18.0.tar.gz", hash = "sha256:cf5661357ff22bc64e20d3341c26e538aa54583aea0763cfe6aaec0ab8e3a8ee", size = 71407, upload-time = "2025-08-26T09:27:01.05Z" } wheels = [ @@ -2067,9 +2092,9 @@ name = "django-multiseek" version = "0.10.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django" }, - { name = "django-autocomplete-light" }, - { name = "python-dateutil" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-autocomplete-light", marker = "platform_python_implementation != 'PyPy'" }, + { name = "python-dateutil", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7c/89/f7a47549eac60dc719b50a541a8f27ff2170a170cd64883903c3c8c5ed99/django_multiseek-0.10.3.tar.gz", hash = "sha256:9aaf2729c06d3b2053305aca382a04e3962a6c05e703e98550e6026a477569a4", size = 39559, upload-time = "2026-08-07T08:38:17.493Z" } wheels = [ @@ -2081,11 +2106,11 @@ name = "django-oauth-toolkit" version = "3.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django" }, - { name = "jwcrypto" }, - { name = "oauthlib" }, - { name = "requests" }, - { name = "urllib3" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, + { name = "jwcrypto", marker = "platform_python_implementation != 'PyPy'" }, + { name = "oauthlib", marker = "platform_python_implementation != 'PyPy'" }, + { name = "requests", marker = "platform_python_implementation != 'PyPy'" }, + { name = "urllib3", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/59/a7/f185dc058f859b104c1fed06ab25eeebbb7e182c3e25c6f7e11e8df3f739/django_oauth_toolkit-3.4.0.tar.gz", hash = "sha256:e43bef1568d6322b44175e27eff82ed7d7cf210889a07d10b8a64e4190ff1351", size = 220227, upload-time = "2026-07-24T02:50:38.186Z" } wheels = [ @@ -2097,7 +2122,7 @@ name = "django-password-policies-iplweb" version = "0.9.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b2/84/b8f652879a3913ca1555f023e8b3912c862e48fd7a00721924778037d3f6/django_password_policies_iplweb-0.9.4.tar.gz", hash = "sha256:e2c02f7f288db169af93574b30117eaccc61e9f7ddab5989254f30230de31c47", size = 98777, upload-time = "2026-08-07T12:50:52.813Z" } wheels = [ @@ -2109,8 +2134,8 @@ name = "django-pbn-client" version = "0.2.2" source = { git = "https://github.com/iplweb/django-pbn-client.git?rev=bb952e98967a96f96cd3773e3dcfcaf4555c8690#bb952e98967a96f96cd3773e3dcfcaf4555c8690" } dependencies = [ - { name = "django" }, - { name = "pbn-client" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pbn-client", marker = "platform_python_implementation != 'PyPy'" }, ] [[package]] @@ -2118,8 +2143,8 @@ name = "django-pg-baseline" version = "0.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django" }, - { name = "testcontainers" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, + { name = "testcontainers", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f0/4c/ed474b704e03446fece5260d49c52f9978a0afa581b850f2521ff1e5c296/django_pg_baseline-0.3.1.tar.gz", hash = "sha256:3b58efe4bbba08de3ea0d84bcf931ffe900d3f53fda7cf77d8228430ff1c46e4", size = 24836, upload-time = "2026-08-07T08:26:30.59Z" } wheels = [ @@ -2131,8 +2156,8 @@ name = "django-polish-inflection" version = "0.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django" }, - { name = "polish-inflection" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, + { name = "polish-inflection", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/86/2a/18d426e30c3ab185046dd33d6c3a37f7e6203b1cf1dc89c4046088795d6a/django_polish_inflection-0.1.1.tar.gz", hash = "sha256:42fa3d2d09a63e68bce1464d6cb5e13a5e257a752e45ad52e6e18faab6d5f822", size = 191869, upload-time = "2026-08-07T08:16:44.657Z" } wheels = [ @@ -2144,8 +2169,8 @@ name = "django-prometheus" version = "2.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django" }, - { name = "prometheus-client" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, + { name = "prometheus-client", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7b/c7/dc39c4c19f7b35e827a486d08376de1fad31c50decb26c56e32668314f13/django_prometheus-2.5.0.tar.gz", hash = "sha256:4837b3c3734d8350880839ab8235aafd250b668c348e159d4aecc3cbefeee53e", size = 26465, upload-time = "2026-05-26T19:04:00.77Z" } wheels = [ @@ -2157,7 +2182,7 @@ name = "django-querysetsequence" version = "0.18" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ce/f6/22906bc0d74b17bd8203e7038b1159f8b228426a3ad24b0912388dcf980e/django_querysetsequence-0.18.tar.gz", hash = "sha256:008435bd44cf291de21238d26efda4a14f4dcb5f01adb19da8339f634f3a9338", size = 30432, upload-time = "2025-05-13T23:27:38.476Z" } wheels = [ @@ -2169,8 +2194,8 @@ name = "django-redis-iplweb" version = "6.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django" }, - { name = "redis" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, + { name = "redis", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/32/93/25dfbe7067f6d09b12f00784fdc193ae47bc102d45d3d42571f7d2af6659/django_redis_iplweb-6.0.2.tar.gz", hash = "sha256:c4771d6297e3083cd275daed750472a2f855a6b29cb592dfb69c0ae8bb2ce8cf", size = 60919, upload-time = "2026-08-07T08:12:58.968Z" } wheels = [ @@ -2182,7 +2207,7 @@ name = "django-render-block" version = "0.11" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6b/2f/588cf58f7b37ab3b7994727d590381aed747173ef5670457c798518621dc/django_render_block-0.11.tar.gz", hash = "sha256:17886dd3bff222fe8a2f019509c6c53e0e4a38fe684e80d48e8697a879e1301b", size = 12432, upload-time = "2025-05-13T11:48:51.333Z" } wheels = [ @@ -2194,7 +2219,7 @@ name = "django-reversion" version = "6.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/88/93/0240c12e07f882e11bc4028b6d5570ff6305ac6237843853b9238895be3a/django_reversion-6.3.0.tar.gz", hash = "sha256:0aa54eb334694f522fd234743978c614d45aa785edc70a6177267cdaaf06f967", size = 79028, upload-time = "2026-06-12T19:55:46.603Z" } wheels = [ @@ -2206,10 +2231,10 @@ name = "django-reversion-compare" version = "0.19.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "diff-match-patch" }, - { name = "django" }, - { name = "django-reversion" }, - { name = "rich" }, + { name = "diff-match-patch", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-reversion", marker = "platform_python_implementation != 'PyPy'" }, + { name = "rich", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f9/28/54ba992a2d1e716fe8950b90791688a5890de4f6f68b0e78a86c8abfaabe/django_reversion_compare-0.19.2.tar.gz", hash = "sha256:b27f0616759eb529693956dca776009d9608900c90a97b09aefeede5d6703bee", size = 194226, upload-time = "2026-02-06T17:18:40.352Z" } wheels = [ @@ -2221,8 +2246,8 @@ name = "django-run-site" version = "0.20.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "docker" }, - { name = "testcontainers", extra = ["redis"] }, + { name = "docker", marker = "platform_python_implementation != 'PyPy'" }, + { name = "testcontainers", extra = ["redis"], marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/23/a1/005f263d837b414d26c560312fe37677b4c933ded17d9a8b4daf63c3d9fb/django_run_site-0.20.2.tar.gz", hash = "sha256:32dd103efa8fc58184bbc9002e4b63ed8dbe547bd86030a2a4b4cf768ae943cf", size = 245654, upload-time = "2026-08-07T08:18:58.883Z" } wheels = [ @@ -2234,7 +2259,7 @@ name = "django-sendfile2" version = "0.7.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6b/8d/18b884f5316329c914b6851f21fd1d88752627ab8da7dd0f0970cf2b58db/django_sendfile2-0.7.2.tar.gz", hash = "sha256:d0939d52217d314b110d0b7b15ace5063d96aeb4980b7e0f94c020c2e25646f8", size = 27488, upload-time = "2025-04-06T14:09:42.806Z" } wheels = [ @@ -2246,7 +2271,7 @@ name = "django-session-security" version = "2.6.8" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3a/7a/69a922ce628b7d91acb48101170741662f641cf7810d4a6273e3e37c702b/django_session_security-2.6.8.tar.gz", hash = "sha256:f8949d021eee5e237385413a1173bb90c3bd80ce2d56e412a5644608de815109", size = 104281, upload-time = "2026-04-24T14:41:42.034Z" } wheels = [ @@ -2258,8 +2283,8 @@ name = "django-site-blog" version = "0.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django" }, - { name = "django-model-utils" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-model-utils", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/56/86/469a6b1e313f50b5b890922b9a4c47adb2da796a5446813c15ec4eb7d52c/django_site_blog-0.2.2.tar.gz", hash = "sha256:c3aa84915ec40962eb23b56c4e608c680fcabd8ef4906b26e41667566fd6d062", size = 13752, upload-time = "2026-08-07T08:17:59.878Z" } wheels = [ @@ -2271,7 +2296,7 @@ name = "django-soft-delete" version = "1.0.23" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/aa/98/c7c52a85b070b1703774df817b6460a7714655302a2d503f6447544f1a29/django_soft_delete-1.0.23.tar.gz", hash = "sha256:814659f0d19d4f2afc58b31ff73f88f0af66715ccef3b4fcd8f6b3a011d59b2a", size = 22458, upload-time = "2026-02-21T17:48:41.345Z" } wheels = [ @@ -2283,9 +2308,9 @@ name = "django-static-sitemaps" version = "5.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django" }, - { name = "setuptools" }, - { name = "six" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, + { name = "setuptools", marker = "platform_python_implementation != 'PyPy'" }, + { name = "six", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/37/86/7d902fe13a60abc8601c6450a940973e2e4f9d4a409b34d6adb4f5ceaa66/django-static-sitemaps-5.0.0.tar.gz", hash = "sha256:4580bda5bbaa4dbf40bc8bbde5936e1675eac1c7b76f65286705befeeefbaa85", size = 8660, upload-time = "2024-02-18T09:04:01.556Z" } @@ -2294,8 +2319,8 @@ name = "django-stubs-ext" version = "6.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django" }, - { name = "typing-extensions" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fb/e6/5dcdaa785ec3eed5fc196c7e68fb7ad9d9fe6d5acccea4690e65f2546417/django_stubs_ext-6.0.3.tar.gz", hash = "sha256:3307d42132bc295d5744de6276bc5fdf6896efc70f891e21c0ae8bdf529d2762", size = 6663, upload-time = "2026-04-18T15:10:53.667Z" } wheels = [ @@ -2307,7 +2332,7 @@ name = "django-tables2" version = "3.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e2/95/8e28e76c04b91d15cf595607040e2e821fd48b517428c2bae5d62af89b18/django_tables2-3.0.0.tar.gz", hash = "sha256:3c5343f72663b0d6684e8e6cbcb16caae10d7621a51c1cf3c581f57ce605f8ee", size = 130242, upload-time = "2026-04-13T07:42:44.463Z" } wheels = [ @@ -2325,7 +2350,7 @@ name = "django-taggit" version = "6.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/34/a6/f1beaf8f552fe90c153cc039316ebab942c23dfbc88588dde081fefca816/django_taggit-6.1.0.tar.gz", hash = "sha256:c4d1199e6df34125dd36db5eb0efe545b254dec3980ce5dd80e6bab3e78757c3", size = 38151, upload-time = "2024-09-29T08:07:39.477Z" } wheels = [ @@ -2337,7 +2362,7 @@ name = "django-templated-email" version = "3.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django-render-block" }, + { name = "django-render-block", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/be/d3/689aee2477dcef5b1fca187897373882fb5aae9f58d1d0a207b8ee7acb94/django_templated_email-3.1.1.tar.gz", hash = "sha256:9c48863d96b89096fa84c6b48523920e90d55397d2895c752fdde5c228aee70f", size = 17026, upload-time = "2025-08-21T17:40:35.162Z" } wheels = [ @@ -2349,7 +2374,7 @@ name = "django-tinymce" version = "5.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0a/71/e9556c8bc1d99c0cb4081af0abc6440d7cf53c87b6c7da7b117f2de24881/django_tinymce-5.0.0.tar.gz", hash = "sha256:6257669ed596accf5fa967ff3061276b2c5352baac1bbc658fcd8252b12ca38a", size = 1161002, upload-time = "2025-10-21T19:25:25.831Z" } wheels = [ @@ -2361,8 +2386,8 @@ name = "django-weasyprint" version = "2.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django" }, - { name = "weasyprint" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, + { name = "weasyprint", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0e/5a/e4109aac85db984a020f4a3cff9836af13ee8bf5bdcf6e61818ea9422a48/django_weasyprint-2.5.0.tar.gz", hash = "sha256:362956b650d2a053967471c0ce15857f08e30253a3eeb746aef7c459d3ee9cf9", size = 14436, upload-time = "2026-04-10T23:03:34.315Z" } wheels = [ @@ -2374,7 +2399,7 @@ name = "django-webmaster-verification" version = "0.4.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/44/3d/f5ddb42ceeedda3098fa691920d77e44b33813834bc44fce40f0ce3fcdeb/django-webmaster-verification-0.4.3.tar.gz", hash = "sha256:1cc6958ee4142da63c1b3e03e537cce6051c740cb1adf6afb68ddb1cc85f4e8f", size = 7125, upload-time = "2022-02-09T09:01:54.524Z" } wheels = [ @@ -2386,7 +2411,7 @@ name = "django-webtest" version = "1.9.14" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "webtest" }, + { name = "webtest", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e4/4c/2c6f4dc32c785b4ed760878f972e0934dba3a4d4cedbfdf4fbce24225808/django_webtest-1.9.14.tar.gz", hash = "sha256:edbdc62f74b84e53ad3b49821e839669c812fafabb54ede713b45209bf732aeb", size = 29289, upload-time = "2025-10-06T21:08:10.761Z" } wheels = [ @@ -2398,7 +2423,7 @@ name = "djangoql-iplweb" version = "0.31.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ply" }, + { name = "ply", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/af/cb/d92ebaaf80dd2569911ff6a4fd9ed5ceb687b9ede56af2f233653b7fb05d/djangoql_iplweb-0.31.2.tar.gz", hash = "sha256:553c63856fd5789624abfd4aa8f0607a29992c4f74069c63c585b15de2b17916", size = 318199, upload-time = "2026-08-07T08:33:40.748Z" } wheels = [ @@ -2410,7 +2435,7 @@ name = "djangorestframework" version = "3.18.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/79/bc/de04e3d4dc65e8b926700956ee70d4f084f2005603d21122d4d0683006fd/djangorestframework-3.18.0.tar.gz", hash = "sha256:2323a5111837e0b784dcb8323abc78ecc54fa2a5af7aff2677cf50cdd849477f", size = 913667, upload-time = "2026-08-07T14:53:33.571Z" } wheels = [ @@ -2422,14 +2447,14 @@ name = "djlint" version = "1.44.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click" }, - { name = "cssbeautifier" }, - { name = "jsbeautifier" }, - { name = "json5" }, - { name = "pathspec" }, - { name = "pyyaml" }, - { name = "regex" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "click", marker = "platform_python_implementation != 'PyPy'" }, + { name = "cssbeautifier", marker = "platform_python_implementation != 'PyPy'" }, + { name = "jsbeautifier", marker = "platform_python_implementation != 'PyPy'" }, + { name = "json5", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pathspec", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pyyaml", marker = "platform_python_implementation != 'PyPy'" }, + { name = "regex", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13' and platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5e/61/b8ad7ed080dd3e6a119e37a3ae2bf26e8a4dc4a06b4d97a22243fa50225a/djlint-1.44.2.tar.gz", hash = "sha256:29944d512818b59d9a1c4ab31851143b456edd327f892329bd3ee3cf7d5da4e9", size = 84909, upload-time = "2026-08-08T15:56:03.825Z" } wheels = [ @@ -2486,9 +2511,9 @@ name = "docker" version = "7.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pywin32", marker = "sys_platform == 'win32'" }, - { name = "requests" }, - { name = "urllib3" }, + { name = "pywin32", marker = "platform_python_implementation != 'PyPy' and sys_platform == 'win32'" }, + { name = "requests", marker = "platform_python_implementation != 'PyPy'" }, + { name = "urllib3", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/91/9b/4a2ea29aeba62471211598dac5d96825bb49348fa07e906ea930394a83ce/docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c", size = 117834, upload-time = "2024-05-23T11:13:57.216Z" } wheels = [ @@ -2509,10 +2534,10 @@ name = "dspace-rest-client" version = "0.1.17" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pysocks" }, - { name = "pysolr" }, - { name = "requests" }, - { name = "smart-open", extra = ["all"] }, + { name = "pysocks", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pysolr", marker = "platform_python_implementation != 'PyPy'" }, + { name = "requests", marker = "platform_python_implementation != 'PyPy'" }, + { name = "smart-open", extra = ["all"], marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/aa/2b/d23dc1619e223c8992a661654222a9f318ce08020c56b216160bbb12173f/dspace_rest_client-0.1.17.tar.gz", hash = "sha256:6a4e91fdaab415a203a2450e7894c258e64fbbc9db128128c9f164528b9a2a46", size = 32393, upload-time = "2026-03-03T04:25:26.587Z" } wheels = [ @@ -2616,8 +2641,8 @@ wheels = [ [package.optional-dependencies] woff = [ { name = "brotli", marker = "platform_python_implementation == 'CPython'" }, - { name = "brotlicffi", marker = "platform_python_implementation != 'CPython'" }, - { name = "zopfli" }, + { name = "brotlicffi", marker = "platform_python_implementation != 'CPython' and platform_python_implementation != 'PyPy'" }, + { name = "zopfli", marker = "platform_python_implementation != 'PyPy'" }, ] [[package]] @@ -2648,11 +2673,11 @@ name = "google-api-core" version = "2.31.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "google-auth" }, - { name = "googleapis-common-protos" }, - { name = "proto-plus" }, - { name = "protobuf" }, - { name = "requests" }, + { name = "google-auth", marker = "platform_python_implementation != 'PyPy'" }, + { name = "googleapis-common-protos", marker = "platform_python_implementation != 'PyPy'" }, + { name = "proto-plus", marker = "platform_python_implementation != 'PyPy'" }, + { name = "protobuf", marker = "platform_python_implementation != 'PyPy'" }, + { name = "requests", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c6/22/155cadf1d49272a9cf48f3168c0f3874fa13397297e611a5ea00cd093880/google_api_core-2.31.0.tar.gz", hash = "sha256:2be84ee0f584c48e6bde1b36766e23348b361fb7e55e56135fc76ce1c397f9c2", size = 176492, upload-time = "2026-06-03T14:52:17.257Z" } wheels = [ @@ -2664,8 +2689,8 @@ name = "google-auth" version = "2.53.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography" }, - { name = "pyasn1-modules" }, + { name = "cryptography", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pyasn1-modules", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c6/ad/ff781329bbbdc0974a098d996e89c9e1f7024262f9e3eec442fbb9ad1ac6/google_auth-2.53.0.tar.gz", hash = "sha256:e7e6aa16f6bee7b2b264830fd04f08087a1d5a836df516251a5d15327b246c9c", size = 335844, upload-time = "2026-05-15T20:53:07.928Z" } wheels = [ @@ -2677,8 +2702,8 @@ name = "google-cloud-core" version = "2.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "google-api-core" }, - { name = "google-auth" }, + { name = "google-api-core", marker = "platform_python_implementation != 'PyPy'" }, + { name = "google-auth", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a8/dd/1eef226e470369b26824a505c34482c0b493bc35fe8e0c6b003b5feca21a/google_cloud_core-2.6.0.tar.gz", hash = "sha256:e76149739f90fac1fc6757c09f47eaccb3145b54adbd7759b0f7c4b235f46c83", size = 36001, upload-time = "2026-05-07T08:04:04.124Z" } wheels = [ @@ -2690,12 +2715,12 @@ name = "google-cloud-storage" version = "3.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "google-api-core" }, - { name = "google-auth" }, - { name = "google-cloud-core" }, - { name = "google-crc32c" }, - { name = "google-resumable-media" }, - { name = "requests" }, + { name = "google-api-core", marker = "platform_python_implementation != 'PyPy'" }, + { name = "google-auth", marker = "platform_python_implementation != 'PyPy'" }, + { name = "google-cloud-core", marker = "platform_python_implementation != 'PyPy'" }, + { name = "google-crc32c", marker = "platform_python_implementation != 'PyPy'" }, + { name = "google-resumable-media", marker = "platform_python_implementation != 'PyPy'" }, + { name = "requests", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/22/09/8953e2993e604c8882fd441b5b2de624a2dfe7e6144c6166d7b477509596/google_cloud_storage-3.11.0.tar.gz", hash = "sha256:498bf37c999028f69a245f586b5e50d89f59df1fafc0e3a93783ac56be2a456b", size = 17335639, upload-time = "2026-06-03T16:14:04.649Z" } wheels = [ @@ -2737,7 +2762,7 @@ name = "google-resumable-media" version = "2.10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "google-crc32c" }, + { name = "google-crc32c", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/48/f8/1ca5781d6be9cb9f73f7d40f4958c4bd1226a60598e3e39e1d6aaf838c4b/google_resumable_media-2.10.0.tar.gz", hash = "sha256:e324bc9d0fdae4c52a08ae90456edc4e71ece858399e1217ac0eb3a51d6bc6ee", size = 2164570, upload-time = "2026-06-03T16:14:26.103Z" } wheels = [ @@ -2749,7 +2774,7 @@ name = "googleapis-common-protos" version = "1.75.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "protobuf" }, + { name = "protobuf", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b5/c8/f439cffde755cffa462bfbb156278fa6f9d09119719af9814b858fd4f81f/googleapis_common_protos-1.75.0.tar.gz", hash = "sha256:53a062ff3c32552fbd62c11fe23768b78e4ddf0494d5e5fd97d3f4689c75fbbd", size = 151035, upload-time = "2026-05-07T08:04:49.423Z" } wheels = [ @@ -2811,7 +2836,7 @@ name = "gunicorn" version = "26.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "packaging" }, + { name = "packaging", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6d/b7/a4a3f632f823e432ce6bc65f62961b7980c898c77f075a2f7118cb3846fe/gunicorn-26.0.0.tar.gz", hash = "sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf", size = 727286, upload-time = "2026-05-05T06:38:25.529Z" } wheels = [ @@ -2832,8 +2857,8 @@ name = "h2" version = "4.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "hpack" }, - { name = "hyperframe" }, + { name = "hpack", marker = "platform_python_implementation != 'PyPy'" }, + { name = "hyperframe", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e7/85/7c366e69d84c17bb778fe41419e1fbcce3033d5b7ce29bbffff0a98b859f/h2-4.4.1.tar.gz", hash = "sha256:4e866ffb1a869ae14dd9b5e6beb5c24a13da0495ad72b65925ded182521c1516", size = 2157281, upload-time = "2026-08-03T11:45:09.509Z" } wheels = [ @@ -2854,14 +2879,40 @@ name = "html5lib" version = "1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "six" }, - { name = "webencodings" }, + { name = "six", marker = "platform_python_implementation != 'PyPy'" }, + { name = "webencodings", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ac/b6/b55c3f49042f1df3dcd422b7f224f939892ee94f22abcf503a9b7339eaf2/html5lib-1.1.tar.gz", hash = "sha256:b2e5b40261e20f354d198eae92afc10d750afb487ed5e50f9c4eaf07c184146f", size = 272215, upload-time = "2020-06-22T23:32:38.834Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/6c/dd/a834df6482147d48e225a49515aabc28974ad5a4ca3215c18a882565b028/html5lib-1.1-py2.py3-none-any.whl", hash = "sha256:0d78f8fde1c230e99fe37986a60526d7049ed4bf8a9fadbad5f00e22e58e041d", size = 112173, upload-time = "2020-06-22T23:32:36.781Z" }, ] +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "h11", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpcore2" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'emscripten'" }, + { name = "truststore", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648", size = 67548, upload-time = "2026-08-18T13:22:08.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/74/d370e55600d9bcfa0d9794b0166126d49291a3d2b20c268fc98c453a4948/httpcore2-2.12.0-py3-none-any.whl", hash = "sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb", size = 83074, upload-time = "2026-08-18T13:22:05.854Z" }, +] + [[package]] name = "httptools" version = "0.8.0" @@ -2905,6 +2956,47 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, ] +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "platform_python_implementation != 'PyPy'" }, + { name = "certifi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "httpcore", marker = "platform_python_implementation != 'PyPy'" }, + { name = "idna", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx2" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "python_full_version >= '3.12' and platform_python_implementation != 'PyPy' and sys_platform == 'emscripten'" }, + { name = "idna", marker = "platform_python_implementation != 'PyPy'" }, + { name = "truststore", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13' and platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/f8/579a8b51e42e38ee32647df9f08aa25643ae788e275cc625b199829c4671/httpx2-2.12.0.tar.gz", hash = "sha256:7631fe9887a8a2275f4a2540e053aa670fcc50742864a9ae7c66e609fdcf12cf", size = 100040, upload-time = "2026-08-18T13:22:09.086Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/95/411ba65569158e862368917aaf56597f3e5fa3b91b0502919638465a08f3/httpx2-2.12.0-py3-none-any.whl", hash = "sha256:cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36", size = 95427, upload-time = "2026-08-18T13:22:06.834Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, +] + [[package]] name = "hyperframe" version = "6.1.0" @@ -2919,7 +3011,7 @@ name = "hyperlink" version = "21.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "idna" }, + { name = "idna", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3a/51/1947bd81d75af87e3bb9e34593a4cf118115a8feb451ce7a69044ef1412e/hyperlink-21.0.0.tar.gz", hash = "sha256:427af957daa58bc909471c6c40f74c5450fa123dd093fc53efd2e91d2705a56b", size = 140743, upload-time = "2021-01-08T05:51:20.972Z" } wheels = [ @@ -2931,7 +3023,7 @@ name = "id" version = "1.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "urllib3" }, + { name = "urllib3", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6d/04/c2156091427636080787aac190019dc64096e56a23b7364d3c1764ee3a06/id-1.6.1.tar.gz", hash = "sha256:d0732d624fb46fd4e7bc4e5152f00214450953b9e772c182c1c22964def1a069", size = 18088, upload-time = "2026-02-04T16:19:41.26Z" } wheels = [ @@ -2949,11 +3041,11 @@ wheels = [ [[package]] name = "idna" -version = "3.16" +version = "3.19" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1a/88/bcf9709822fe69d02c2a6a77956c98ce6ea8ca8767a9aadcedc7eb6a2390/idna-3.16.tar.gz", hash = "sha256:d7a6da03db833450fca25d2358ac9ff06cd624577a4aea3a596d5c0f77b8e03d", size = 203770, upload-time = "2026-05-22T00:16:18.781Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/16/70255075a9859a0e3adb789b68ceb0e210dec03934245fd98d248226572f/idna-3.16-py3-none-any.whl", hash = "sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5", size = 74165, upload-time = "2026-05-22T00:16:16.698Z" }, + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, ] [[package]] @@ -2970,7 +3062,7 @@ name = "importlib-metadata" version = "9.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp" }, + { name = "zipp", marker = "python_full_version < '3.12' and platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } wheels = [ @@ -2982,7 +3074,7 @@ name = "incremental" version = "24.7.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "setuptools" }, + { name = "setuptools", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/27/87/156b374ff6578062965afe30cc57627d35234369b3336cf244b240c8d8e6/incremental-24.7.2.tar.gz", hash = "sha256:fb4f1d47ee60efe87d4f6f0ebb5f70b9760db2b2574c59c8e8912be4ebd464c9", size = 28157, upload-time = "2024-07-29T20:03:55.441Z" } wheels = [ @@ -3012,8 +3104,8 @@ name = "ipdb" version = "0.13.13" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "decorator" }, - { name = "ipython" }, + { name = "decorator", marker = "platform_python_implementation != 'PyPy'" }, + { name = "ipython", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3d/1b/7e07e7b752017f7693a0f4d41c13e5ca29ce8cbcfdcc1fd6c4ad8c0a27a0/ipdb-0.13.13.tar.gz", hash = "sha256:e3ac6018ef05126d442af680aad863006ec19d02290561ac88b8b1c0b0cfc726", size = 17042, upload-time = "2023-03-09T15:40:57.487Z" } wheels = [ @@ -3025,16 +3117,16 @@ name = "ipython" version = "8.37.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "decorator" }, - { name = "jedi" }, - { name = "matplotlib-inline" }, - { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit" }, - { name = "pygments" }, - { name = "stack-data" }, - { name = "traitlets" }, - { name = "typing-extensions", marker = "python_full_version < '3.12'" }, + { name = "colorama", marker = "platform_python_implementation != 'PyPy' and sys_platform == 'win32'" }, + { name = "decorator", marker = "platform_python_implementation != 'PyPy'" }, + { name = "jedi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "matplotlib-inline", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pexpect", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pygments", marker = "platform_python_implementation != 'PyPy'" }, + { name = "stack-data", marker = "platform_python_implementation != 'PyPy'" }, + { name = "traitlets", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.12' and platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/85/31/10ac88f3357fc276dc8a64e8880c82e80e7459326ae1d0a211b40abf6665/ipython-8.37.0.tar.gz", hash = "sha256:ca815841e1a41a1e6b73a0b08f3038af9b2252564d01fc405356d34033012216", size = 5606088, upload-time = "2025-05-31T16:39:09.613Z" } wheels = [ @@ -3064,7 +3156,7 @@ name = "jaraco-classes" version = "3.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "more-itertools" }, + { name = "more-itertools", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } wheels = [ @@ -3076,7 +3168,7 @@ name = "jaraco-context" version = "6.1.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, + { name = "backports-tarfile", marker = "python_full_version < '3.12' and platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801, upload-time = "2026-03-20T22:13:33.922Z" } wheels = [ @@ -3088,7 +3180,7 @@ name = "jaraco-functools" version = "4.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "more-itertools" }, + { name = "more-itertools", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0f/27/056e0638a86749374d6f57d0b0db39f29509cce9313cf91bdc0ac4d91084/jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb", size = 19943, upload-time = "2025-12-21T09:29:43.6Z" } wheels = [ @@ -3100,7 +3192,7 @@ name = "jedi" version = "0.19.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "parso" }, + { name = "parso", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287, upload-time = "2024-11-11T01:41:42.873Z" } wheels = [ @@ -3121,7 +3213,7 @@ name = "jinja2" version = "3.1.6" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markupsafe" }, + { name = "markupsafe", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } wheels = [ @@ -3142,8 +3234,8 @@ name = "jsbeautifier" version = "1.15.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "editorconfig" }, - { name = "six" }, + { name = "editorconfig", marker = "platform_python_implementation != 'PyPy'" }, + { name = "six", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ea/98/d6cadf4d5a1c03b2136837a435682418c29fdeb66be137128544cecc5b7a/jsbeautifier-1.15.4.tar.gz", hash = "sha256:5bb18d9efb9331d825735fbc5360ee8f1aac5e52780042803943aa7f854f7592", size = 75257, upload-time = "2025-02-27T17:53:53.252Z" } wheels = [ @@ -3159,13 +3251,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b8/42/cf027b4ac873b076189d935b135397675dac80cb29acb13e1ab86ad6c631/json5-0.14.0-py3-none-any.whl", hash = "sha256:56cf861bab076b1178eb8c92e1311d273a9b9acea2ccc82c276abf839ebaef3a", size = 36271, upload-time = "2026-03-27T22:50:47.073Z" }, ] +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs", marker = "platform_python_implementation != 'PyPy'" }, + { name = "jsonschema-specifications", marker = "platform_python_implementation != 'PyPy'" }, + { name = "referencing", marker = "platform_python_implementation != 'PyPy'" }, + { name = "rpds-py", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + [[package]] name = "jwcrypto" version = "1.5.8" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography" }, - { name = "typing-extensions" }, + { name = "cryptography", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/de/7e/53c14813521693e931e420d9db00efe317419ff194495903bce03402275e/jwcrypto-1.5.8.tar.gz", hash = "sha256:c3d7114b6f6e65b52f6b7da817eb8cb8423e1da31e1ef13508447c81ecbdcc34", size = 90772, upload-time = "2026-06-24T19:36:50.782Z" } wheels = [ @@ -3177,13 +3296,13 @@ name = "keyring" version = "25.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, - { name = "jaraco-classes" }, - { name = "jaraco-context" }, - { name = "jaraco-functools" }, - { name = "jeepney", marker = "sys_platform == 'linux'" }, - { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, - { name = "secretstorage", marker = "sys_platform == 'linux'" }, + { name = "importlib-metadata", marker = "python_full_version < '3.12' and platform_python_implementation != 'PyPy'" }, + { name = "jaraco-classes", marker = "platform_python_implementation != 'PyPy'" }, + { name = "jaraco-context", marker = "platform_python_implementation != 'PyPy'" }, + { name = "jaraco-functools", marker = "platform_python_implementation != 'PyPy'" }, + { name = "jeepney", marker = "platform_python_implementation != 'PyPy' and sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "platform_python_implementation != 'PyPy' and sys_platform == 'win32'" }, + { name = "secretstorage", marker = "platform_python_implementation != 'PyPy' and sys_platform == 'linux'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" } wheels = [ @@ -3195,10 +3314,10 @@ name = "kombu" version = "5.6.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "amqp" }, - { name = "packaging" }, - { name = "tzdata" }, - { name = "vine" }, + { name = "amqp", marker = "platform_python_implementation != 'PyPy'" }, + { name = "packaging", marker = "platform_python_implementation != 'PyPy'" }, + { name = "tzdata", marker = "platform_python_implementation != 'PyPy'" }, + { name = "vine", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b6/a5/607e533ed6c83ae1a696969b8e1c137dfebd5759a2e9682e26ff1b97740b/kombu-5.6.2.tar.gz", hash = "sha256:8060497058066c6f5aed7c26d7cd0d3b574990b09de842a8c5aaed0b92cc5a55", size = 472594, upload-time = "2025-12-29T20:30:07.779Z" } wheels = [ @@ -3210,7 +3329,7 @@ name = "langdetect" version = "1.0.9" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "six" }, + { name = "six", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0e/72/a3add0e4eec4eb9e2569554f7c70f4a3c27712f40e3284d483e88094cc0e/langdetect-1.0.9.tar.gz", hash = "sha256:cbc1fef89f8d062739774bd51eda3da3274006b3661d199c2655f6b3f6d605a0", size = 981474, upload-time = "2021-05-07T07:54:13.562Z" } @@ -3410,7 +3529,7 @@ name = "markdown-it-py" version = "4.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "mdurl" }, + { name = "mdurl", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } wheels = [ @@ -3496,13 +3615,51 @@ name = "matplotlib-inline" version = "0.1.7" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "traitlets" }, + { name = "traitlets", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/99/5b/a36a337438a14116b16480db471ad061c36c3694df7c2084a0da7ba538b7/matplotlib_inline-0.1.7.tar.gz", hash = "sha256:8423b23ec666be3d16e16b60bdd8ac4e86e840ebd1dd11a30b9f117f2fa0ab90", size = 8159, upload-time = "2024-04-15T13:44:44.803Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/8f/8e/9ad090d3553c280a8060fbf6e24dc1c0c29704ee7d1c372f0c174aa59285/matplotlib_inline-0.1.7-py3-none-any.whl", hash = "sha256:df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca", size = 9899, upload-time = "2024-04-15T13:44:43.265Z" }, ] +[[package]] +name = "mcp" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "platform_python_implementation != 'PyPy'" }, + { name = "httpx2", marker = "platform_python_implementation != 'PyPy'" }, + { name = "jsonschema", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mcp-types", marker = "platform_python_implementation != 'PyPy'" }, + { name = "opentelemetry-api", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pydantic", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pyjwt", extra = ["crypto"], marker = "platform_python_implementation != 'PyPy'" }, + { name = "python-multipart", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pywin32", marker = "platform_python_implementation != 'PyPy' and sys_platform == 'win32'" }, + { name = "sse-starlette", marker = "platform_python_implementation != 'PyPy'" }, + { name = "starlette", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-inspection", marker = "platform_python_implementation != 'PyPy'" }, + { name = "uvicorn", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d4/6e/21fb8e5d579dbe21d96ea4d5034200d46d8bdf2261053b5bd041f3c2f612/mcp-2.1.1.tar.gz", hash = "sha256:50b7ba1ebbe117008ea7bdd288234043e69c20b403d6851d19661e6d431a75ef", size = 3984589, upload-time = "2026-08-25T16:14:02.376Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/af/8644cc5fa26a59afd2df2e98eeb19e72926887fa4b7441aba4ff661140db/mcp-2.1.1-py3-none-any.whl", hash = "sha256:1c6c31c5d6471c58db76af3af8af67f46d11d01f0a59077d0a308cbdb3d3e915", size = 357912, upload-time = "2026-08-25T16:13:59.024Z" }, +] + +[[package]] +name = "mcp-types" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/dd/1c4417dc0b722c23a1669032d5f044e41170fe5d4773b488a50fcce98c32/mcp_types-2.1.1.tar.gz", hash = "sha256:77dcbe48fba73cca71a673f2646a5f037a017b7a0a07ac89cec1113028890eda", size = 66674, upload-time = "2026-08-25T16:14:03.861Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/d0/242e63c510f4a17381f55b1549a3f94f5687a0595984febd2b6f87a687a0/mcp_types-2.1.1-py3-none-any.whl", hash = "sha256:26f9f7f03f2a5730717a5b98e2ab7eb640ac352d05a00cdc725c311864778295", size = 69656, upload-time = "2026-08-25T16:14:00.667Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -3553,14 +3710,14 @@ name = "moai-iplweb" version = "2.0.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "oaipmh" }, - { name = "paste" }, - { name = "pastedeploy" }, - { name = "pastescript" }, - { name = "sqlalchemy" }, - { name = "webob" }, - { name = "wsgi-intercept" }, - { name = "wsgiutils" }, + { name = "oaipmh", marker = "platform_python_implementation != 'PyPy'" }, + { name = "paste", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pastedeploy", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pastescript", marker = "platform_python_implementation != 'PyPy'" }, + { name = "sqlalchemy", marker = "platform_python_implementation != 'PyPy'" }, + { name = "webob", marker = "platform_python_implementation != 'PyPy'" }, + { name = "wsgi-intercept", marker = "platform_python_implementation != 'PyPy'" }, + { name = "wsgiutils", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1e/c2/2ccf7a12e5289e16b2a186df385777cce0906bb02aba5d4c299030be7c00/moai_iplweb-2.0.4.tar.gz", hash = "sha256:007eb7046534eac474801b655b55ac208b788011b1e94c00a1f9380c25413be1", size = 35500, upload-time = "2026-08-20T09:35:28.759Z" } wheels = [ @@ -3572,7 +3729,7 @@ name = "model-bakery" version = "1.24.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f9/49/3e5b51d7af52bf39e348767c1feb16c97d18654a8e3f3306eb4b899e98cf/model_bakery-1.24.0.tar.gz", hash = "sha256:6469223c9fb0eccb021d5c33667b914e9208dd2282d7dc1e55394c2310a7687d", size = 28372, upload-time = "2026-06-23T21:55:23.19Z" } wheels = [ @@ -3593,10 +3750,10 @@ name = "mozilla-django-oidc" version = "5.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography" }, - { name = "django" }, - { name = "pyjwt" }, - { name = "requests" }, + { name = "cryptography", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pyjwt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "requests", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/04/5e/d5906dc016253d248eabf3608a6bbfd0df75a993a467cf7cf0a0c7f2b18a/mozilla_django_oidc-5.0.2.tar.gz", hash = "sha256:4e953dcd963c036daaa2ac42b5bb6ea89a1c6ea7be0387c2022a59aca2f83043", size = 57360, upload-time = "2025-12-19T16:11:40.685Z" } wheels = [ @@ -3802,7 +3959,7 @@ name = "oaipmh" version = "3.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "lxml" }, + { name = "lxml", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/58/0a/ec17bcfb5d1c3d25d9b5852cb59683182c29055ce3fae3cac6fd2c954e93/oaipmh-3.2.0.tar.gz", hash = "sha256:79235ef64cfdb39863919689c854e68989b6f9c40d016b01b65eaabce2bc0d4c", size = 26060, upload-time = "2026-05-15T18:04:18.455Z" } wheels = [ @@ -3823,24 +3980,36 @@ name = "openpyxl" version = "3.1.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "et-xmlfile" }, + { name = "et-xmlfile", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/8b/aa9e2d8b8dfa7c946f7dec5d1f8f6ba8eca062f43509a06bdb5ce93d26c0/opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a", size = 72406, upload-time = "2026-07-16T15:25:32.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef", size = 60018, upload-time = "2026-07-16T15:25:11.657Z" }, +] + [[package]] name = "ortools" version = "9.15.6755" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "absl-py" }, - { name = "immutabledict" }, - { name = "numpy" }, - { name = "pandas" }, - { name = "protobuf" }, - { name = "typing-extensions" }, + { name = "absl-py", marker = "platform_python_implementation != 'PyPy'" }, + { name = "immutabledict", marker = "platform_python_implementation != 'PyPy'" }, + { name = "numpy", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pandas", marker = "platform_python_implementation != 'PyPy'" }, + { name = "protobuf", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "platform_python_implementation != 'PyPy'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/0b/16/a08369f1d2022b5aea8d9ceede08df417622015447b817019e11ef4a1d9b/ortools-9.15.6755-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:55e291560d2fdb9590656cbee06ba99ee7f2476bd7d316ff757eeab33e9b20d6", size = 23886068, upload-time = "2026-01-14T15:38:55.095Z" }, @@ -3874,7 +4043,7 @@ name = "oscrypto" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "asn1crypto" }, + { name = "asn1crypto", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/06/81/a7654e654a4b30eda06ef9ad8c1b45d1534bfd10b5c045d0c0f6b16fecd2/oscrypto-1.3.0.tar.gz", hash = "sha256:6f5fef59cb5b3708321db7cca56aed8ad7e662853351e7991fcf60ec606d47a4", size = 184590, upload-time = "2022-03-18T01:53:26.889Z" } wheels = [ @@ -3895,9 +4064,9 @@ name = "pandas" version = "3.0.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy" }, - { name = "python-dateutil" }, - { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, + { name = "numpy", marker = "platform_python_implementation != 'PyPy'" }, + { name = "python-dateutil", marker = "platform_python_implementation != 'PyPy'" }, + { name = "tzdata", marker = "(platform_python_implementation != 'PyPy' and sys_platform == 'emscripten') or (platform_python_implementation != 'PyPy' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/be/4f/5f3422a2afec5ffc46308b79e53291365a93748b498ac2e58bead0197916/pandas-3.0.5.tar.gz", hash = "sha256:dca3734d6ab7c906e6730f0788b0a1dbb9f2467731f9711f77995c8e9d62d712", size = 4658219, upload-time = "2026-07-22T22:19:28.819Z" } wheels = [ @@ -3949,10 +4118,10 @@ name = "paramiko" version = "5.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "bcrypt" }, - { name = "cryptography" }, - { name = "invoke" }, - { name = "pynacl" }, + { name = "bcrypt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "cryptography", marker = "platform_python_implementation != 'PyPy'" }, + { name = "invoke", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pynacl", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/62/93/dcc25d52f49022ae6175d15e6bd751f1acc99b98bc61fc55e5155a7be2e7/paramiko-5.0.0.tar.gz", hash = "sha256:36763b5b95c2a0dcfdf1abc48e48156ee425b21efe2f0e787c2dd5a95c0e5e79", size = 1548586, upload-time = "2026-05-09T18:28:52.256Z" } wheels = [ @@ -3973,7 +4142,7 @@ name = "paste" version = "3.10.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "setuptools" }, + { name = "setuptools", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d7/1c/6bc9040bf9b4cfc9334f66d2738f952384c106c48882adf6097fed3da966/paste-3.10.1.tar.gz", hash = "sha256:1c3d12065a5e8a7a18c0c7be1653a97cf38cc3e9a5a0c8334a9dd992d3a05e4a", size = 652629, upload-time = "2024-05-01T11:41:08.536Z" } wheels = [ @@ -3994,9 +4163,9 @@ name = "pastescript" version = "3.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "paste" }, - { name = "pastedeploy" }, - { name = "setuptools" }, + { name = "paste", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pastedeploy", marker = "platform_python_implementation != 'PyPy'" }, + { name = "setuptools", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/25/c3/a12bc48375d6408eeef371a5c386ef4f62dac56ae35ae2eab042e00689e6/pastescript-3.7.0.tar.gz", hash = "sha256:536f028dfb6e5329c67b32299e1c9303f91264e49793cc6916eb0a73eb4a2521", size = 98747, upload-time = "2025-01-22T16:15:28.827Z" } wheels = [ @@ -4017,8 +4186,8 @@ name = "pbn-client" version = "0.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "requests" }, - { name = "simplejson" }, + { name = "requests", marker = "platform_python_implementation != 'PyPy'" }, + { name = "simplejson", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fb/53/ef61b6f58592eef44a27a216e9a7687a782a09f5e3f2d499860e59bd9c0d/pbn_client-0.2.2.tar.gz", hash = "sha256:21b78873fbea40c4e13cee39c75ea3e1411429dd21a9c772c2fac83963e3f6c4", size = 64750, upload-time = "2026-08-05T07:27:16.427Z" } wheels = [ @@ -4030,7 +4199,7 @@ name = "pexpect" version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ptyprocess" }, + { name = "ptyprocess", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } wheels = [ @@ -4115,8 +4284,8 @@ name = "playwright" version = "1.62.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "greenlet" }, - { name = "pyee" }, + { name = "greenlet", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pyee", marker = "platform_python_implementation != 'PyPy'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/6c/5b/ca2abcf3aa69f9fb510215e3064f30b57fe57657c8d04ede45bb966d5606/playwright-1.62.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:d8da938f3748841a8754f2e1f0216902c1c8f8ae3720de8b32ccf8e6913a7c4f", size = 43732091, upload-time = "2026-07-31T17:00:44.178Z" }, @@ -4152,8 +4321,8 @@ name = "polish-inflection" version = "0.7.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "marisa-trie" }, - { name = "polish-inflection-data" }, + { name = "marisa-trie", marker = "platform_python_implementation != 'PyPy'" }, + { name = "polish-inflection-data", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6d/21/53697694f0f813f5cc8c6a61c02b3d797edbe9337734d1dab749c690d2bd/polish_inflection-0.7.3.tar.gz", hash = "sha256:28aed6b2043e005298b1191f383c94a2d9eb29aa0210865d31f78b5baa96dc92", size = 70472, upload-time = "2026-07-03T20:21:01.566Z" } wheels = [ @@ -4174,11 +4343,11 @@ name = "pre-commit" version = "4.6.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cfgv" }, - { name = "identify" }, - { name = "nodeenv" }, - { name = "pyyaml" }, - { name = "virtualenv" }, + { name = "cfgv", marker = "platform_python_implementation != 'PyPy'" }, + { name = "identify", marker = "platform_python_implementation != 'PyPy'" }, + { name = "nodeenv", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pyyaml", marker = "platform_python_implementation != 'PyPy'" }, + { name = "virtualenv", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/74/89/1f3e8e1fc3e97de0fa963495832f581f025f29471602a309e48808244292/pre_commit-4.6.2.tar.gz", hash = "sha256:8f5d7bfb021ecdbcd9d49d89847082dd24172ccde534390081a679ad046e2441", size = 198670, upload-time = "2026-08-10T22:07:18.421Z" } wheels = [ @@ -4208,7 +4377,7 @@ name = "prompt-toolkit" version = "3.0.52" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "wcwidth" }, + { name = "wcwidth", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } wheels = [ @@ -4220,7 +4389,7 @@ name = "proto-plus" version = "1.28.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "protobuf" }, + { name = "protobuf", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c9/56/e647b0c675392d2da368da7b6f158f7368b18542fd6f7d7400a2f39de000/proto_plus-1.28.0.tar.gz", hash = "sha256:38e5696342835b08fc116f30a25665b29531cda9d5d5643e9b81fc312385abd9", size = 57221, upload-time = "2026-05-07T08:04:50.811Z" } wheels = [ @@ -4354,7 +4523,7 @@ name = "pyasn1-modules" version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyasn1" }, + { name = "pyasn1", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } wheels = [ @@ -4392,6 +4561,123 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, ] +[[package]] +name = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pydantic-core", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-inspection", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/b6/81d2d19ea0be2c03664381b59f65fa72fc7969decedae00bc2c4ad835708/pydantic_core-2.46.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f", size = 2074737, upload-time = "2026-08-28T09:57:57.711Z" }, + { url = "https://files.pythonhosted.org/packages/0c/18/b70da8300e292df4099684ea11b1958043580d2f50d2dc8bf7e542bdd84a/pydantic_core-2.46.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f", size = 1921751, upload-time = "2026-08-28T09:57:59.265Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1a/0d590341b6ffa4b4aca83508e6b8db4761aaeacfc15a25ca3815876d4797/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061", size = 1948231, upload-time = "2026-08-28T09:58:00.678Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/02eb35761c51f2f7b1b042d6ab4cda6600f0c8c88a2243b3f734376201e5/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be", size = 2020708, upload-time = "2026-08-28T09:58:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ea/f86073830e35d508cc8ddf9c3d9e6e6840fcb88d34bf726b0b4710186f27/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a", size = 2194914, upload-time = "2026-08-28T09:58:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d7/fc36240d7791ce90939e51608568c33bfdae26202016f9770c229a487d86/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b", size = 2235622, upload-time = "2026-08-28T09:58:05.516Z" }, + { url = "https://files.pythonhosted.org/packages/cf/bc/3fa2d76b83162820a17da7f645b28d1cba99fc8e1e5fc6517067ec450fa1/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c", size = 2062091, upload-time = "2026-08-28T09:58:07.135Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9a/095d557bb492c90cd8a70a6dd048bf793d433d03d86c81c11e912e4cd049/pydantic_core-2.46.5-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee", size = 2089904, upload-time = "2026-08-28T09:58:08.814Z" }, + { url = "https://files.pythonhosted.org/packages/24/98/7b76b1ad10a19a617a52aaa1d80e159115af939b095e86f8e756fd52e0df/pydantic_core-2.46.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e", size = 2132244, upload-time = "2026-08-28T09:58:10.435Z" }, + { url = "https://files.pythonhosted.org/packages/20/32/7d6ca365fadba186a0c8f85de1a701663bce81efd309d9479be58687622f/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2", size = 2143901, upload-time = "2026-08-28T09:58:12.033Z" }, + { url = "https://files.pythonhosted.org/packages/f8/09/eb9a6aa57f22fd1541a9c0aa2a1f3aeef3ec65347d33e10a6da2f43e0ee9/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689", size = 2299425, upload-time = "2026-08-28T09:58:13.614Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f9/548a5bb9d4ba8cd26e26daf48052236f6b38bb61e7b7241fbc3c995719eb/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec", size = 2318566, upload-time = "2026-08-28T09:58:15.199Z" }, + { url = "https://files.pythonhosted.org/packages/4a/20/06454d18834c02c406c9133f1a3b485305fd9ee984f9636c2f730bef6a9d/pydantic_core-2.46.5-cp311-cp311-win32.whl", hash = "sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129", size = 1954258, upload-time = "2026-08-28T09:58:16.813Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c2/718b9deb4b72453b5d8c7447a3b14cb77bef36917ef5f514e0948a4096a0/pydantic_core-2.46.5-cp311-cp311-win_amd64.whl", hash = "sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c", size = 2041030, upload-time = "2026-08-28T09:58:18.288Z" }, + { url = "https://files.pythonhosted.org/packages/67/ea/c1d1a5b72d6e1ff7f377a4d9199f6591f095beb5b409a8a5d89f7238d939/pydantic_core-2.46.5-cp311-cp311-win_arm64.whl", hash = "sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8", size = 2009234, upload-time = "2026-08-28T09:58:19.929Z" }, + { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" }, + { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" }, + { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" }, + { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" }, + { url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" }, + { url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" }, + { url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" }, + { url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" }, + { url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" }, + { url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" }, + { url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" }, + { url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" }, + { url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" }, + { url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" }, + { url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" }, + { url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" }, + { url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" }, + { url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" }, + { url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" }, + { url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" }, + { url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" }, + { url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" }, + { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, + { url = "https://files.pythonhosted.org/packages/af/1e/ecca01fce348f7e8afa9572441ff6f7d1cc70d21e4859f33944d10877e1e/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2", size = 2075342, upload-time = "2026-08-28T10:00:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4c/af80c7a8032dfc897040ad5cb772bebde529a381186499e6e29987f23f8c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c", size = 1907219, upload-time = "2026-08-28T10:00:53.438Z" }, + { url = "https://files.pythonhosted.org/packages/be/3e/54d89e2b092e778716bf6153634ef479e955f48c261090be23aa1e0fb0b5/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47", size = 1953393, upload-time = "2026-08-28T10:00:55.58Z" }, + { url = "https://files.pythonhosted.org/packages/ea/89/828ee90cda28ce17bdefaa3a6eaf74fe430e113295a10e6126beca559d6c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a", size = 2099024, upload-time = "2026-08-28T10:00:57.794Z" }, + { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" }, + { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" }, + { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, + { url = "https://files.pythonhosted.org/packages/20/21/22102e9950b3049526d20e811b95396508377d87651edd2b80d2b3d28659/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f", size = 2071333, upload-time = "2026-08-28T10:01:09.636Z" }, + { url = "https://files.pythonhosted.org/packages/d8/18/87aefa427d191e6d3ab1447f1efc1cdcac86af1069239b133e8a0fd7f7c9/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0", size = 1912713, upload-time = "2026-08-28T10:01:12.285Z" }, + { url = "https://files.pythonhosted.org/packages/1f/93/fd89e9ad49b1805ca94d24ce1088b7d305f05c35ffafcedb9819d03588a0/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4", size = 2090926, upload-time = "2026-08-28T10:01:15.19Z" }, + { url = "https://files.pythonhosted.org/packages/6f/45/8e59dab6acf8d35f02f0a958980074f31038968bdb2c983fcae9d1efee03/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25", size = 2131303, upload-time = "2026-08-28T10:01:17.937Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a5/e1d4dc5180dd887a9522efc1f8716b8692b7606b1d3273d7862eaf66be44/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6", size = 2145128, upload-time = "2026-08-28T10:01:20.694Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/ad493864a7fb21c0c4df98f965e2db430cb25a9d7369b5778d5016c09fd9/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e", size = 2294560, upload-time = "2026-08-28T10:01:23.495Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/b41c84c913f29973a268e6c2b5bbf13c95adb9956c126d10da11ba3b2bef/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda", size = 2317531, upload-time = "2026-08-28T10:01:26.334Z" }, + { url = "https://files.pythonhosted.org/packages/db/1d/068464f23075f66a8f1b806935e9cd9363ee446636ea70d2c22ee8659dbf/pydantic_core-2.46.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266", size = 2140686, upload-time = "2026-08-28T10:01:28.947Z" }, +] + [[package]] name = "pydyf" version = "0.11.0" @@ -4406,7 +4692,7 @@ name = "pyee" version = "13.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" } wheels = [ @@ -4418,8 +4704,8 @@ name = "pygad" version = "3.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cloudpickle" }, - { name = "numpy" }, + { name = "cloudpickle", marker = "platform_python_implementation != 'PyPy'" }, + { name = "numpy", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a4/af/05a6d18c27d196cbdce1131aef480441928940dfa33d55dc95dc701b420b/pygad-3.7.0.tar.gz", hash = "sha256:9858226f1b1482ab845b3129152fe9ca4457a7bcae5804717647beacd00b734c", size = 266300, upload-time = "2026-06-05T22:38:52.979Z" } wheels = [ @@ -4440,13 +4726,13 @@ name = "pyhanko" version = "0.32.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "asn1crypto" }, - { name = "cryptography" }, - { name = "lxml" }, - { name = "pyhanko-certvalidator" }, - { name = "pyyaml" }, - { name = "requests" }, - { name = "tzlocal" }, + { name = "asn1crypto", marker = "platform_python_implementation != 'PyPy'" }, + { name = "cryptography", marker = "platform_python_implementation != 'PyPy'" }, + { name = "lxml", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pyhanko-certvalidator", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pyyaml", marker = "platform_python_implementation != 'PyPy'" }, + { name = "requests", marker = "platform_python_implementation != 'PyPy'" }, + { name = "tzlocal", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/bf/d5/9be7c6a62d9fe9ad824f4a3144e7f55bff11198446fe4ec7f96cd3f9673c/pyhanko-0.32.0.tar.gz", hash = "sha256:47df283f14289b9df72071c3e5c52c426998f1850e21bff9d6451e4369595820", size = 411900, upload-time = "2025-11-22T16:22:07.543Z" } wheels = [ @@ -4458,11 +4744,11 @@ name = "pyhanko-certvalidator" version = "0.29.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "asn1crypto" }, - { name = "cryptography" }, - { name = "oscrypto" }, - { name = "requests" }, - { name = "uritools" }, + { name = "asn1crypto", marker = "platform_python_implementation != 'PyPy'" }, + { name = "cryptography", marker = "platform_python_implementation != 'PyPy'" }, + { name = "oscrypto", marker = "platform_python_implementation != 'PyPy'" }, + { name = "requests", marker = "platform_python_implementation != 'PyPy'" }, + { name = "uritools", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a0/3d/3e41e3b492b84e327c2340c380439c6805f9d2a3b574f662041958f19373/pyhanko_certvalidator-0.29.0.tar.gz", hash = "sha256:8ebb98e742e4a2e2347374535c1329abf77c398addb31e623f33645ace02efa4", size = 93212, upload-time = "2025-09-12T22:05:37.207Z" } wheels = [ @@ -4480,7 +4766,7 @@ wheels = [ [package.optional-dependencies] crypto = [ - { name = "cryptography" }, + { name = "cryptography", marker = "platform_python_implementation != 'PyPy'" }, ] [[package]] @@ -4494,7 +4780,7 @@ name = "pymed-iplweb" version = "0.8.11" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "requests" }, + { name = "requests", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/44/85/5600c04401f34e2c6b13f243f41db6aa52342040ffc941908b1c3b9ab210/pymed_iplweb-0.8.11.tar.gz", hash = "sha256:ec24b07388fdec59fcb8ccfb926e5254f27ef06b039b017c80162bccb158854a", size = 11535, upload-time = "2026-04-16T11:50:21.128Z" } wheels = [ @@ -4506,7 +4792,7 @@ name = "pynacl" version = "1.6.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi" }, + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d9/9a/4019b524b03a13438637b11538c82781a5eda427394380381af8f04f467a/pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c", size = 3511692, upload-time = "2026-01-01T17:48:10.851Z" } wheels = [ @@ -4541,8 +4827,8 @@ name = "pyopenssl" version = "26.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "cryptography", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13' and platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3f/e8/7325d258199b159eb2c03fe32107533e2832e70e63f4fb88a6aa00023201/pyopenssl-26.4.0.tar.gz", hash = "sha256:28dfcce0162b9211413e26dfbfdf1d24317fbeba18fc93c12400a1856b2a0bc7", size = 182046, upload-time = "2026-08-01T19:50:50.512Z" } wheels = [ @@ -4599,8 +4885,8 @@ name = "pysolr" version = "3.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "requests" }, - { name = "setuptools" }, + { name = "requests", marker = "platform_python_implementation != 'PyPy'" }, + { name = "setuptools", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0d/2a/b6c793a17e173524ccc7a90cdd8ebb65264f6ebcdb1942eba2478ec3a85e/pysolr-3.11.0.tar.gz", hash = "sha256:bbd0e7467835316880f994b55386c9bc3c146733c17d85a3fed317755976c40a", size = 63983, upload-time = "2025-11-18T10:42:40.247Z" } @@ -4609,11 +4895,11 @@ name = "pytest" version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "iniconfig" }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, + { name = "colorama", marker = "platform_python_implementation != 'PyPy' and sys_platform == 'win32'" }, + { name = "iniconfig", marker = "platform_python_implementation != 'PyPy'" }, + { name = "packaging", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pluggy", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pygments", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ @@ -4625,8 +4911,8 @@ name = "pytest-base-url" version = "2.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pytest" }, - { name = "requests" }, + { name = "pytest", marker = "platform_python_implementation != 'PyPy'" }, + { name = "requests", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ae/1a/b64ac368de6b993135cb70ca4e5d958a5c268094a3a2a4cac6f0021b6c4f/pytest_base_url-2.1.0.tar.gz", hash = "sha256:02748589a54f9e63fcbe62301d6b0496da0d10231b753e950c63e03aee745d45", size = 6702, upload-time = "2024-01-31T22:43:00.81Z" } wheels = [ @@ -4638,9 +4924,9 @@ name = "pytest-cov" version = "7.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "coverage", extra = ["toml"] }, - { name = "pluggy" }, - { name = "pytest" }, + { name = "coverage", extra = ["toml"], marker = "platform_python_implementation != 'PyPy'" }, + { name = "pluggy", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pytest", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } wheels = [ @@ -4652,7 +4938,7 @@ name = "pytest-django" version = "4.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pytest" }, + { name = "pytest", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/44/f6/3851312120c2bf2f19cafff931e75059aad1ba670703cd751e2fde9bc942/pytest_django-4.14.0.tar.gz", hash = "sha256:26787dd3f422cfbab8f55b80a776e2edea7a11092cb74e960bef1312515708ef", size = 94700, upload-time = "2026-08-10T14:13:08.319Z" } wheels = [ @@ -4664,8 +4950,8 @@ name = "pytest-env" version = "1.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pytest" }, - { name = "python-dotenv" }, + { name = "pytest", marker = "platform_python_implementation != 'PyPy'" }, + { name = "python-dotenv", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/65/49/08ee056f9cc655e437abcf2ae399884844b623223476ae6a77244131db03/pytest_env-1.7.0.tar.gz", hash = "sha256:0c1dc1101fb8d3ab3611e8f8d657ba06c3c0c167fc85c90457e5b27f2508f43e", size = 16408, upload-time = "2026-07-21T13:09:21.834Z" } wheels = [ @@ -4677,7 +4963,7 @@ name = "pytest-httpserver" version = "1.1.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "werkzeug" }, + { name = "werkzeug", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/17/ad187f46998814014f7cda309de700b87c0eb4b2e111e18bc8c819be7116/pytest_httpserver-1.1.5.tar.gz", hash = "sha256:dc3d82e1fe00e491829d8939c549bf4bd9b39a260f87113c619b9d517c2f8ff1", size = 70974, upload-time = "2026-02-14T13:27:23.412Z" } wheels = [ @@ -4689,7 +4975,7 @@ name = "pytest-mock" version = "3.15.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pytest" }, + { name = "pytest", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } wheels = [ @@ -4701,10 +4987,10 @@ name = "pytest-playwright" version = "0.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "playwright" }, - { name = "pytest" }, - { name = "pytest-base-url" }, - { name = "python-slugify" }, + { name = "playwright", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pytest", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pytest-base-url", marker = "platform_python_implementation != 'PyPy'" }, + { name = "python-slugify", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/26/c4/31cab1a9edfa35546950bb30c033dbfe4d4f3a216f4bafb0537a1469a70c/pytest_playwright-0.9.0.tar.gz", hash = "sha256:bd44daa852b0fb8b0e55a2f88b727507dedda0e350bea5d09420647252f2454b", size = 17899, upload-time = "2026-08-10T10:20:16.407Z" } wheels = [ @@ -4716,8 +5002,8 @@ name = "pytest-recording" version = "0.13.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pytest" }, - { name = "vcrpy" }, + { name = "pytest", marker = "platform_python_implementation != 'PyPy'" }, + { name = "vcrpy", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/32/9c/f4027c5f1693847b06d11caf4b4f6bb09f22c1581ada4663877ec166b8c6/pytest_recording-0.13.4.tar.gz", hash = "sha256:568d64b2a85992eec4ae0a419c855d5fd96782c5fb016784d86f18053792768c", size = 26576, upload-time = "2025-05-08T10:41:11.231Z" } wheels = [ @@ -4729,7 +5015,7 @@ name = "pytest-repeat" version = "0.9.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pytest" }, + { name = "pytest", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/80/d4/69e9dbb9b8266df0b157c72be32083403c412990af15c7c15f7a3fd1b142/pytest_repeat-0.9.4.tar.gz", hash = "sha256:d92ac14dfaa6ffcfe6917e5d16f0c9bc82380c135b03c2a5f412d2637f224485", size = 6488, upload-time = "2025-04-07T14:59:53.077Z" } wheels = [ @@ -4741,8 +5027,8 @@ name = "pytest-rerunfailures" version = "16.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "packaging" }, - { name = "pytest" }, + { name = "packaging", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pytest", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0b/4f/615f3d41cacf8641534304037daeb63b1f80a98a130019cb9b743e4b8e1e/pytest_rerunfailures-16.5.tar.gz", hash = "sha256:1481b45e6eb73228d19979c4481a8532007fadc6881d1198622cad1901c4afb2", size = 41475, upload-time = "2026-08-13T07:12:16.415Z" } wheels = [ @@ -4754,7 +5040,7 @@ name = "pytest-split" version = "0.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pytest" }, + { name = "pytest", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/2f/16/8af4c5f2ceb3640bb1f78dfdf5c184556b10dfe9369feaaad7ff1c13f329/pytest_split-0.11.0.tar.gz", hash = "sha256:8ebdb29cc72cc962e8eb1ec07db1eeb98ab25e215ed8e3216f6b9fc7ce0ec2b5", size = 13421, upload-time = "2026-02-03T09:14:31.469Z" } wheels = [ @@ -4766,9 +5052,9 @@ name = "pytest-testcontainers" version = "0.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "docker" }, - { name = "pytest" }, - { name = "testcontainers" }, + { name = "docker", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pytest", marker = "platform_python_implementation != 'PyPy'" }, + { name = "testcontainers", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a4/a1/08eb167860f55a7f4053511579566f4fb79135b4c4a54b44fd344f127b48/pytest_testcontainers-0.3.2.tar.gz", hash = "sha256:918b7048e13e5c928ed049f9f1d56b05b5ef9c55925b9b8df8172df61cac42cb", size = 57072, upload-time = "2026-08-07T15:10:04.933Z" } wheels = [ @@ -4780,10 +5066,10 @@ name = "pytest-testcontainers-django" version = "0.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pytest" }, - { name = "pytest-django" }, - { name = "pytest-testcontainers" }, - { name = "testcontainers" }, + { name = "pytest", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pytest-django", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pytest-testcontainers", marker = "platform_python_implementation != 'PyPy'" }, + { name = "testcontainers", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b5/26/c4d4a2e2e25edaab9e586e6955ecd970c9767b0c8b945e5e6ef5824e1971/pytest_testcontainers_django-0.3.2.tar.gz", hash = "sha256:20c18926638e5f82df1841482ce198eb7c9ecd3c4b64fb5593a5b9dc3d3cebf0", size = 35288, upload-time = "2026-08-07T15:11:58.196Z" } wheels = [ @@ -4795,7 +5081,7 @@ name = "pytest-timeout" version = "2.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pytest" }, + { name = "pytest", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" } wheels = [ @@ -4807,8 +5093,8 @@ name = "pytest-tqdm" version = "0.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pytest" }, - { name = "tqdm" }, + { name = "pytest", marker = "platform_python_implementation != 'PyPy'" }, + { name = "tqdm", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0a/27/08b7a2e45d469fd052a6c6719946300160a8bf5603b101922363922e0f69/pytest_tqdm-0.4.0.tar.gz", hash = "sha256:eefa4e26145e7ad7d4f3e1ec23c5000f4115d74ea1c19daa7487ca70e832e19a", size = 15113, upload-time = "2026-07-13T15:11:57.19Z" } wheels = [ @@ -4820,8 +5106,8 @@ name = "pytest-xdist" version = "3.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "execnet" }, - { name = "pytest" }, + { name = "execnet", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pytest", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } wheels = [ @@ -4903,7 +5189,7 @@ name = "python-dateutil" version = "2.9.0.post0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "six" }, + { name = "six", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } wheels = [ @@ -4915,8 +5201,8 @@ name = "python-discovery" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "filelock" }, - { name = "platformdirs" }, + { name = "filelock", marker = "platform_python_implementation != 'PyPy'" }, + { name = "platformdirs", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/48/60/e88788207d81e46362cfbef0d4aaf4c0f49efc3c12d4c3fa3f542c34ebec/python_discovery-1.3.1.tar.gz", hash = "sha256:62f6db28064c9613e7ca76cb3f00c38c839a07c31c00dfe7ed0986493d2150a6", size = 68011, upload-time = "2026-05-12T20:53:36.336Z" } wheels = [ @@ -4928,8 +5214,8 @@ name = "python-docx" version = "1.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "lxml" }, - { name = "typing-extensions" }, + { name = "lxml", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a9/f7/eddfe33871520adab45aaa1a71f0402a2252050c14c7e3009446c8f4701c/python_docx-1.2.0.tar.gz", hash = "sha256:7bc9d7b7d8a69c9c02ca09216118c86552704edc23bac179283f2e38f86220ce", size = 5723256, upload-time = "2025-06-16T20:46:27.921Z" } wheels = [ @@ -4950,17 +5236,26 @@ name = "python-ldap" version = "3.4.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyasn1" }, - { name = "pyasn1-modules" }, + { name = "pyasn1", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pyasn1-modules", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0c/88/8d2797decc42e1c1cdd926df4f005e938b0643d0d1219c08c2b5ee8ae0c0/python_ldap-3.4.5.tar.gz", hash = "sha256:b2f6ef1c37fe2c6a5a85212efe71311ee21847766a7d45fcb711f3b270a5f79a", size = 388482, upload-time = "2025-10-10T20:00:39.06Z" } +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + [[package]] name = "python-slugify" version = "8.0.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "text-unidecode" }, + { name = "text-unidecode", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/87/c7/5e1547c44e31da50a460df93af11a535ace568ef89d7a811069ead340c4a/python-slugify-8.0.4.tar.gz", hash = "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856", size = 10921, upload-time = "2024-02-08T18:32:45.488Z" } wheels = [ @@ -5055,7 +5350,7 @@ name = "questionary" version = "2.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "prompt-toolkit" }, + { name = "prompt-toolkit", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" } wheels = [ @@ -5192,9 +5487,9 @@ name = "readme-renderer" version = "44.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "docutils" }, - { name = "nh3" }, - { name = "pygments" }, + { name = "docutils", marker = "platform_python_implementation != 'PyPy'" }, + { name = "nh3", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pygments", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5a/a9/104ec9234c8448c4379768221ea6df01260cd6c2ce13182d4eac531c8342/readme_renderer-44.0.tar.gz", hash = "sha256:8712034eabbfa6805cacf1402b4eeb2a73028f72d1166d6f5cb7f9c047c5d1e1", size = 32056, upload-time = "2024-07-08T15:00:57.805Z" } wheels = [ @@ -5206,13 +5501,27 @@ name = "redis" version = "7.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, + { name = "async-timeout", marker = "python_full_version < '3.11.3' and platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7b/7f/3759b1d0d72b7c92f0d70ffd9dc962b7b7b5ee74e135f9d7d8ab06b8a318/redis-7.4.0.tar.gz", hash = "sha256:64a6ea7bf567ad43c964d2c30d82853f8df927c5c9017766c55a1d1ed95d18ad", size = 4943913, upload-time = "2026-03-24T09:14:37.53Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/74/3a/95deec7db1eb53979973ebd156f3369a72732208d1391cd2e5d127062a32/redis-7.4.0-py3-none-any.whl", hash = "sha256:a9c74a5c893a5ef8455a5adb793a31bb70feb821c86eccb62eebef5a19c429ec", size = 409772, upload-time = "2026-03-24T09:14:35.968Z" }, ] +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs", marker = "platform_python_implementation != 'PyPy'" }, + { name = "rpds-py", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13' and platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + [[package]] name = "regex" version = "2026.5.9" @@ -5322,8 +5631,8 @@ name = "reportlab" version = "4.4.6" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "charset-normalizer" }, - { name = "pillow" }, + { name = "charset-normalizer", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pillow", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/35/ec/f7a50b3cbee58407090bd1f2a9db2f1a23052c5de3bc7408024ca776ee02/reportlab-4.4.6.tar.gz", hash = "sha256:8792c87c23dd034d17530e6ebe4164d61bcc8f7b0eac203fe13cc03cc2c1c607", size = 3910805, upload-time = "2025-12-10T12:37:21.17Z" } wheels = [ @@ -5335,10 +5644,10 @@ name = "requests" version = "2.34.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "idna" }, - { name = "urllib3" }, + { name = "certifi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "charset-normalizer", marker = "platform_python_implementation != 'PyPy'" }, + { name = "idna", marker = "platform_python_implementation != 'PyPy'" }, + { name = "urllib3", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } wheels = [ @@ -5350,8 +5659,8 @@ name = "requests-oauthlib" version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "oauthlib" }, - { name = "requests" }, + { name = "oauthlib", marker = "platform_python_implementation != 'PyPy'" }, + { name = "requests", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" } wheels = [ @@ -5363,7 +5672,7 @@ name = "requests-toolbelt" version = "1.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "requests" }, + { name = "requests", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } wheels = [ @@ -5384,8 +5693,8 @@ name = "rich" version = "15.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markdown-it-py" }, - { name = "pygments" }, + { name = "markdown-it-py", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pygments", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } wheels = [ @@ -5441,8 +5750,8 @@ name = "rlpycairo" version = "0.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "freetype-py" }, - { name = "pycairo" }, + { name = "freetype-py", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pycairo", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/4b/24/09b18821e06de45394d34dda706d34169db6bddd6a403346caa3496b3668/rlpycairo-0.4.0.tar.gz", hash = "sha256:07c2c3c47828e83d9c09657a54ecbcd1a97aac9dc199780234456d3473faadc7", size = 7692, upload-time = "2025-08-15T12:25:22.761Z" } wheels = [ @@ -5454,13 +5763,107 @@ name = "rollbar" version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "requests" }, + { name = "requests", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3b/3f/92e8c728e42aba86db5f8d6e2e9c23926b2d8df81a02cdcab0c044e285e9/rollbar-1.4.0.tar.gz", hash = "sha256:98ad7f90012c791d0b40eac51b6fe688b1cadcc4aadff772fc0a23efe75fb5fc", size = 94401, upload-time = "2026-07-10T16:32:15.518Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e7/0b/383e2a843722a256406edaae37561ff124af6633369920bef6ea3daa1678/rollbar-1.4.0-py3-none-any.whl", hash = "sha256:4f5c183ce1cdc6316a2a675f887a6ccead3f9b5da35b8d6c4ad7a7c2904c602d", size = 126652, upload-time = "2026-07-10T16:32:14.289Z" }, ] +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/1f/a2dca5ffdbf1d475ffc4e80e4d5d720ff3a00f691795910116960ee12511/rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7", size = 342174, upload-time = "2026-06-30T07:14:54.821Z" }, + { url = "https://files.pythonhosted.org/packages/4d/dc/323d08583c0832911768663d1944f0107fcd4088704858d84b5e06d105a0/rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911", size = 345513, upload-time = "2026-06-30T07:14:56.515Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2a/e31989834d18d2f26ec1d2774c5b1eb3331df4ea8ada525175294c94b48a/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4", size = 373783, upload-time = "2026-06-30T07:14:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/fe/e80107ee3639585c9941c17d6a42cd65325022f656c023191fce78c324c8/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261", size = 378316, upload-time = "2026-06-30T07:14:59.077Z" }, + { url = "https://files.pythonhosted.org/packages/22/6f/81e3adf81acfb6fa694de2a6e4e7d8863121e3e0799e0a7725e6cf5679c4/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278", size = 499423, upload-time = "2026-06-30T07:15:00.488Z" }, + { url = "https://files.pythonhosted.org/packages/2d/9a/41263969df0ce3d9af2a96d5005a288200af1989aed3354bfceb5fc0b21f/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9", size = 386077, upload-time = "2026-06-30T07:15:01.911Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/7e98f468bd50346faff5b10e5297374b443bfdddacc8e9fbc65984539597/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7", size = 371315, upload-time = "2026-06-30T07:15:03.317Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/2b973b4d371906a134b03decfea7f5d9835a2c6d263454392e15b64b5b18/rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3", size = 383502, upload-time = "2026-06-30T07:15:04.627Z" }, + { url = "https://files.pythonhosted.org/packages/98/2a/12e2799500af0a307bca76b63361c51f9fe479223561489c29eea1f2ee41/rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da", size = 402673, upload-time = "2026-06-30T07:15:05.856Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e3/21e5872d165fe08be4f229e3d5ee9d90019c0bf0e5538de60dbd54009450/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4", size = 549964, upload-time = "2026-06-30T07:15:07.159Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d0/5ee0fe36844297de8123bee27bc12078c1a7416ad9f1b8a8ca18d6b0c0ac/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6", size = 615446, upload-time = "2026-06-30T07:15:08.531Z" }, + { url = "https://files.pythonhosted.org/packages/b1/80/1ea5873cb683f2fbe5f21b23ea1f6d179ead19f3c5b249b7eb5dca568ef2/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93", size = 576975, upload-time = "2026-06-30T07:15:09.97Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e1/90ef639217a5ddb15b7f4f61b1c33911fd044ad03c311bafdd2bcab85582/rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a", size = 204453, upload-time = "2026-06-30T07:15:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b7/b7a1695d7af36f521fb11e80d6d3adbd744f73b921859bd3c2a2c0dc706f/rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127", size = 223219, upload-time = "2026-06-30T07:15:12.476Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a2/145afacf796e4506062825941176ad9445c2dcf2b3b6a1f13d3030a15e19/rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804", size = 219137, upload-time = "2026-06-30T07:15:13.631Z" }, + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, + { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, + { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, + { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, + { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, + { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, + { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/b4/9c/f0d19ac587fd0e4ab6b72cda355e9c5a6166b01ef7e064e437aef8eb9fef/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f", size = 349791, upload-time = "2026-06-30T07:17:33.315Z" }, + { url = "https://files.pythonhosted.org/packages/38/c7/1d49d204c9fd2ee6c537601dc4c1ba921e03363ca576bfab94a00254ac9a/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171", size = 352842, upload-time = "2026-06-30T07:17:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e5/c0b5dc93cd0d4c06ce1f438907649514e2ea077bcd911e3154a51e96c38e/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90", size = 382094, upload-time = "2026-06-30T07:17:36.514Z" }, + { url = "https://files.pythonhosted.org/packages/0d/54/ec0e907b4ca8d541112db352409bd15f871c9b243e0c92c9b5a46ae96f01/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca", size = 388662, upload-time = "2026-06-30T07:17:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f4/921c22a4fd0f1c1ac13a3996ffbf0aa67951e2c8ad0d1d9574938a2932e8/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9", size = 504896, upload-time = "2026-06-30T07:17:39.689Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1b/a114b972cefa1ab1cdb3c7bb177cd3844a12826c507c722d3a73516dbbaf/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c", size = 391545, upload-time = "2026-06-30T07:17:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/4e/98/af9b3db77d47fcbe6c8c1f36e2c2147ec70292819e99c325f871584a1c11/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9", size = 380059, upload-time = "2026-06-30T07:17:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ba/0efd8668b97c1d26a61566386c636a7a7a09829e474fdf807caa15a2c844/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41", size = 393235, upload-time = "2026-06-30T07:17:44.637Z" }, + { url = "https://files.pythonhosted.org/packages/62/90/8c139ee9690f73b0829f32647de6f40d826f8f443af6fa72644f96351aac/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c", size = 413008, upload-time = "2026-06-30T07:17:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/0043896fdd7828ce09a1d9a8b06433714d0960fc4ff3fc4aa72b666b764e/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9", size = 558118, upload-time = "2026-06-30T07:17:47.759Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/02355f0e134f783a8f9814c4680a1bd311d37671577a5964ea838573ff37/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76", size = 623138, upload-time = "2026-06-30T07:17:49.355Z" }, + { url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486, upload-time = "2026-06-30T07:17:51.141Z" }, +] + [[package]] name = "ruff" version = "0.16.3" @@ -5491,7 +5894,7 @@ name = "s3transfer" version = "0.18.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "botocore" }, + { name = "botocore", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e0/1f/12417f7f493fc45e1f9fd5d4a9b6c125cf8d2cf3f8ddbdfab3e76406e9d6/s3transfer-0.18.0.tar.gz", hash = "sha256:3760b8b7ec1315da54048b2d626276732bee4300d054d492d4e1d43e20d4ecbd", size = 160560, upload-time = "2026-05-28T19:39:09.124Z" } wheels = [ @@ -5503,8 +5906,8 @@ name = "secretstorage" version = "3.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography" }, - { name = "jeepney" }, + { name = "cryptography", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "jeepney", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } wheels = [ @@ -5516,10 +5919,10 @@ name = "service-identity" version = "24.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "attrs" }, - { name = "cryptography" }, - { name = "pyasn1" }, - { name = "pyasn1-modules" }, + { name = "attrs", marker = "platform_python_implementation != 'PyPy'" }, + { name = "cryptography", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pyasn1", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pyasn1-modules", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/07/a5/dfc752b979067947261dbbf2543470c58efe735c3c1301dd870ef27830ee/service_identity-24.2.0.tar.gz", hash = "sha256:b8683ba13f0d39c6cd5d625d2c5f65421d6d707b013b375c355751557cbe8e09", size = 39245, upload-time = "2024-10-26T07:21:57.736Z" } wheels = [ @@ -5613,7 +6016,7 @@ name = "smart-open" version = "7.0.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "wrapt" }, + { name = "wrapt", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a3/d8/1481294b2d110b805c0f5d23ef34158b7d5d4283633c0d34c69ea89bb76b/smart_open-7.0.5.tar.gz", hash = "sha256:d3672003b1dbc85e2013e4983b88eb9a5ccfd389b0d4e5015f39a9ee5620ec18", size = 71693, upload-time = "2024-10-04T13:58:32.442Z" } wheels = [ @@ -5622,14 +6025,14 @@ wheels = [ [package.optional-dependencies] all = [ - { name = "azure-common" }, - { name = "azure-core" }, - { name = "azure-storage-blob" }, - { name = "boto3" }, - { name = "google-cloud-storage" }, - { name = "paramiko" }, - { name = "requests" }, - { name = "zstandard" }, + { name = "azure-common", marker = "platform_python_implementation != 'PyPy'" }, + { name = "azure-core", marker = "platform_python_implementation != 'PyPy'" }, + { name = "azure-storage-blob", marker = "platform_python_implementation != 'PyPy'" }, + { name = "boto3", marker = "platform_python_implementation != 'PyPy'" }, + { name = "google-cloud-storage", marker = "platform_python_implementation != 'PyPy'" }, + { name = "paramiko", marker = "platform_python_implementation != 'PyPy'" }, + { name = "requests", marker = "platform_python_implementation != 'PyPy'" }, + { name = "zstandard", marker = "platform_python_implementation != 'PyPy'" }, ] [[package]] @@ -5655,7 +6058,7 @@ name = "sqlalchemy" version = "1.4.54" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "greenlet", marker = "(platform_machine == 'AMD64' and platform_python_implementation != 'PyPy') or (platform_machine == 'WIN32' and platform_python_implementation != 'PyPy') or (platform_machine == 'aarch64' and platform_python_implementation != 'PyPy') or (platform_machine == 'amd64' and platform_python_implementation != 'PyPy') or (platform_machine == 'ppc64le' and platform_python_implementation != 'PyPy') or (platform_machine == 'win32' and platform_python_implementation != 'PyPy') or (platform_machine == 'x86_64' and platform_python_implementation != 'PyPy')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ce/af/20290b55d469e873cba9d41c0206ab5461ff49d759989b3fe65010f9d265/sqlalchemy-1.4.54.tar.gz", hash = "sha256:4470fbed088c35dc20b78a39aaf4ae54fe81790c783b3264872a0224f437c31a", size = 8470350, upload-time = "2024-09-05T15:54:10.398Z" } wheels = [ @@ -5680,30 +6083,56 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/50/f00935da0ec7cbf325f8dc4f772ae46fbc7b672dd62876e73f0a94adda57/sqlparse-0.6.0-py3-none-any.whl", hash = "sha256:b861c0288ce2fa56209a9a6412d2e066ac664b3873b89c26c9d8415e8e32996f", size = 50070, upload-time = "2026-08-13T19:16:04.062Z" }, ] +[[package]] +name = "sse-starlette" +version = "3.4.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "platform_python_implementation != 'PyPy'" }, + { name = "starlette", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2b/54/6767bb789b2f2fed6e0f953df949cd39dc263a384c1b65a95232598621d6/sse_starlette-3.4.11.tar.gz", hash = "sha256:1bae716c02f3e6f294be41ff333220692dae7c3cbab077c900f159676719dade", size = 34972, upload-time = "2026-09-05T12:11:04.607Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/6a/2ba3ed4a69babf3afdddf7d8314a48d87562c0a442206bbc2a1b50d5efc0/sse_starlette-3.4.11-py3-none-any.whl", hash = "sha256:c7b2244bdff016fe7f64e10075e89a3e6bbf899649cc89b0fe884b5545042453", size = 17122, upload-time = "2026-09-05T12:11:03.195Z" }, +] + [[package]] name = "stack-data" version = "0.6.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "asttokens" }, - { name = "executing" }, - { name = "pure-eval" }, + { name = "asttokens", marker = "platform_python_implementation != 'PyPy'" }, + { name = "executing", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pure-eval", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, ] +[[package]] +name = "starlette" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13' and platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" }, +] + [[package]] name = "svglib" version = "1.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cssselect2" }, - { name = "lxml" }, - { name = "reportlab" }, - { name = "rlpycairo" }, - { name = "tinycss2" }, + { name = "cssselect2", marker = "platform_python_implementation != 'PyPy'" }, + { name = "lxml", marker = "platform_python_implementation != 'PyPy'" }, + { name = "reportlab", marker = "platform_python_implementation != 'PyPy'" }, + { name = "rlpycairo", marker = "platform_python_implementation != 'PyPy'" }, + { name = "tinycss2", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/88/16/6b5f9f8c8b18d9ab5112d11e2d6c40c965e3d7ab2948b744685fe6d2f9f5/svglib-1.6.0.tar.gz", hash = "sha256:4c38a274a744ef0d1677f55d5d62fc0fb798819f813e52872a796e615741733d", size = 1318276, upload-time = "2025-09-25T09:48:37.839Z" } wheels = [ @@ -5724,11 +6153,11 @@ name = "testcontainers" version = "4.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "docker" }, - { name = "python-dotenv" }, - { name = "typing-extensions" }, - { name = "urllib3" }, - { name = "wrapt" }, + { name = "docker", marker = "platform_python_implementation != 'PyPy'" }, + { name = "python-dotenv", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "platform_python_implementation != 'PyPy'" }, + { name = "urllib3", marker = "platform_python_implementation != 'PyPy'" }, + { name = "wrapt", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/4b/13/2cc466bddf26d0085f30a2b2bd56b7f8708b54a54db833eec97c5c69129b/testcontainers-4.15.0.tar.gz", hash = "sha256:085cde086337632e19002719460b7b80bbab2bdd51bb3ea04f77d0de96504706", size = 95340, upload-time = "2026-07-24T23:08:01.731Z" } wheels = [ @@ -5737,7 +6166,7 @@ wheels = [ [package.optional-dependencies] redis = [ - { name = "redis" }, + { name = "redis", marker = "platform_python_implementation != 'PyPy'" }, ] [[package]] @@ -5754,7 +6183,7 @@ name = "thefuzz" version = "0.22.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "rapidfuzz" }, + { name = "rapidfuzz", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/81/4b/d3eb25831590d6d7d38c2f2e3561d3ba41d490dc89cd91d9e65e7c812508/thefuzz-0.22.1.tar.gz", hash = "sha256:7138039a7ecf540da323792d8592ef9902b1d79eb78c147d4f20664de79f3680", size = 19993, upload-time = "2024-01-19T19:18:23.135Z" } wheels = [ @@ -5766,7 +6195,7 @@ name = "tinycss2" version = "1.5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "webencodings" }, + { name = "webencodings", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a3/ae/2ca4913e5c0f09781d75482874c3a95db9105462a92ddd303c7d285d3df2/tinycss2-1.5.1.tar.gz", hash = "sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957", size = 88195, upload-time = "2025-11-23T10:29:10.082Z" } wheels = [ @@ -5778,7 +6207,7 @@ name = "tinyhtml5" version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "webencodings" }, + { name = "webencodings", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fd/03/6111ed99e9bf7dfa1c30baeef0e0fb7e0bd387bd07f8e5b270776fe1de3f/tinyhtml5-2.0.0.tar.gz", hash = "sha256:086f998833da24c300c414d9fe81d9b368fd04cb9d2596a008421cbc705fcfcc", size = 179507, upload-time = "2024-10-29T15:37:14.078Z" } wheels = [ @@ -5853,8 +6282,8 @@ name = "towncrier" version = "25.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click" }, - { name = "jinja2" }, + { name = "click", marker = "platform_python_implementation != 'PyPy'" }, + { name = "jinja2", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c2/eb/5bf25a34123698d3bbab39c5bc5375f8f8bcbcc5a136964ade66935b8b9d/towncrier-25.8.0.tar.gz", hash = "sha256:eef16d29f831ad57abb3ae32a0565739866219f1ebfbdd297d32894eb9940eb1", size = 76322, upload-time = "2025-08-30T11:41:55.393Z" } wheels = [ @@ -5866,7 +6295,7 @@ name = "tqdm" version = "4.70.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "colorama", marker = "platform_python_implementation != 'PyPy' and sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/21/3b/6c24bec5be5e743ffd99576daa5cc077722fc7d5bbc00bd133fa0c698dc6/tqdm-4.70.0.tar.gz", hash = "sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220", size = 795438, upload-time = "2026-07-27T11:33:15.271Z" } wheels = [ @@ -5882,20 +6311,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359, upload-time = "2024-04-19T11:11:46.763Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "twine" version = "7.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "id" }, - { name = "keyring", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'" }, - { name = "packaging" }, - { name = "readme-renderer" }, - { name = "requests" }, - { name = "requests-toolbelt" }, - { name = "rfc3986" }, - { name = "rich" }, - { name = "urllib3" }, + { name = "id", marker = "platform_python_implementation != 'PyPy'" }, + { name = "keyring", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x' and platform_python_implementation != 'PyPy'" }, + { name = "packaging", marker = "platform_python_implementation != 'PyPy'" }, + { name = "readme-renderer", marker = "platform_python_implementation != 'PyPy'" }, + { name = "requests", marker = "platform_python_implementation != 'PyPy'" }, + { name = "requests-toolbelt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "rfc3986", marker = "platform_python_implementation != 'PyPy'" }, + { name = "rich", marker = "platform_python_implementation != 'PyPy'" }, + { name = "urllib3", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/92/3c/58f808a359700f39a967dffede33efeac809262c03303fa3eec6afff8f49/twine-7.0.0.tar.gz", hash = "sha256:85cdb29c518efef867360ae4acd4b0dfd61c8654a22fca08e6f8539f05022177", size = 215032, upload-time = "2026-07-27T15:59:00.825Z" } wheels = [ @@ -5907,13 +6345,13 @@ name = "twisted" version = "26.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "attrs" }, - { name = "automat" }, - { name = "constantly" }, - { name = "hyperlink" }, - { name = "incremental" }, - { name = "typing-extensions" }, - { name = "zope-interface" }, + { name = "attrs", marker = "platform_python_implementation != 'PyPy'" }, + { name = "automat", marker = "platform_python_implementation != 'PyPy'" }, + { name = "constantly", marker = "platform_python_implementation != 'PyPy'" }, + { name = "hyperlink", marker = "platform_python_implementation != 'PyPy'" }, + { name = "incremental", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "platform_python_implementation != 'PyPy'" }, + { name = "zope-interface", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/db/97/6e9beb1e78247ae6dc34114f27d538cf2cb183c4afcd3609dfdf2b0439c8/twisted-26.4.0.tar.gz", hash = "sha256:dbfd0fe1ee409d0243fdd7a6a6ff14f4948cec1fd78e0376291f805e1501fae9", size = 3575095, upload-time = "2026-05-11T11:24:51.861Z" } wheels = [ @@ -5922,13 +6360,13 @@ wheels = [ [package.optional-dependencies] http2 = [ - { name = "h2" }, - { name = "priority" }, + { name = "h2", marker = "platform_python_implementation != 'PyPy'" }, + { name = "priority", marker = "platform_python_implementation != 'PyPy'" }, ] tls = [ - { name = "idna" }, - { name = "pyopenssl" }, - { name = "service-identity" }, + { name = "idna", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pyopenssl", marker = "platform_python_implementation != 'PyPy'" }, + { name = "service-identity", marker = "platform_python_implementation != 'PyPy'" }, ] [[package]] @@ -5949,6 +6387,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + [[package]] name = "tzdata" version = "2025.2" @@ -5963,7 +6413,7 @@ name = "tzlocal" version = "5.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "tzdata", marker = "sys_platform == 'win32'" }, + { name = "tzdata", marker = "platform_python_implementation != 'PyPy' and sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } wheels = [ @@ -6002,8 +6452,8 @@ name = "uvicorn" version = "0.52.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click" }, - { name = "h11" }, + { name = "click", marker = "platform_python_implementation != 'PyPy'" }, + { name = "h11", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/2e/28/64ca011edf31c715b4fad359c587ea52391aaffa125065695590241ff617/uvicorn-0.52.3.tar.gz", hash = "sha256:18857b9e6579300be55c91c0a1cfd37d9a2cf0cabea33b88275f199eb73b8b58", size = 100621, upload-time = "2026-08-13T16:50:02.899Z" } wheels = [ @@ -6012,12 +6462,12 @@ wheels = [ [package.optional-dependencies] standard = [ - { name = "httptools" }, - { name = "python-dotenv" }, - { name = "pyyaml" }, - { name = "uvloop", marker = "sys_platform != 'cygwin' and sys_platform != 'win32'" }, - { name = "watchfiles" }, - { name = "websockets" }, + { name = "httptools", marker = "platform_python_implementation != 'PyPy'" }, + { name = "python-dotenv", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pyyaml", marker = "platform_python_implementation != 'PyPy'" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles", marker = "platform_python_implementation != 'PyPy'" }, + { name = "websockets", marker = "platform_python_implementation != 'PyPy'" }, ] [[package]] @@ -6025,8 +6475,8 @@ name = "uvicorn-worker" version = "0.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "gunicorn" }, - { name = "uvicorn" }, + { name = "gunicorn", marker = "platform_python_implementation != 'PyPy'" }, + { name = "uvicorn", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/80/59/9101b9c0680fd80e9d26c07deb822a5d18a324339fcf9cd017885ee808ad/uvicorn_worker-0.4.0.tar.gz", hash = "sha256:8ee5306070d8f38dce124adce488c3c0b50f20cf0c0222b12c66188da7214493", size = 9361, upload-time = "2025-09-20T10:47:01.218Z" } wheels = [ @@ -6064,8 +6514,8 @@ name = "vcrpy" version = "8.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyyaml" }, - { name = "wrapt" }, + { name = "pyyaml", marker = "platform_python_implementation != 'PyPy'" }, + { name = "wrapt", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/39/d5/8a1f8eb603e2d35fbb0ecd1e309d0c5c18a0ecfc8c0a8f04088bbc8f833b/vcrpy-8.3.0.tar.gz", hash = "sha256:46d64e77e8d95e5c76c7d9a94ff05d8b38b2ae4e1d4869eb0235024b6fcb5212", size = 96117, upload-time = "2026-07-04T14:27:01.608Z" } wheels = [ @@ -6086,10 +6536,10 @@ name = "virtualenv" version = "21.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "distlib" }, - { name = "filelock" }, - { name = "platformdirs" }, - { name = "python-discovery" }, + { name = "distlib", marker = "platform_python_implementation != 'PyPy'" }, + { name = "filelock", marker = "platform_python_implementation != 'PyPy'" }, + { name = "platformdirs", marker = "platform_python_implementation != 'PyPy'" }, + { name = "python-discovery", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/69/e1/665267cea4767debd19f584667a9197c2098b5e7f67a502da9f3a086ab37/virtualenv-21.3.2.tar.gz", hash = "sha256:3ecda97894a6fc1c53106356f488690e5c86278c1f693f3fc0805ac85a513686", size = 7613810, upload-time = "2026-05-12T14:44:18.01Z" } wheels = [ @@ -6146,7 +6596,7 @@ name = "watchfiles" version = "1.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, + { name = "anyio", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } wheels = [ @@ -6242,14 +6692,14 @@ name = "weasyprint" version = "69.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi" }, - { name = "cssselect2" }, - { name = "fonttools", extra = ["woff"] }, - { name = "pillow" }, - { name = "pydyf" }, - { name = "pyphen" }, - { name = "tinycss2" }, - { name = "tinyhtml5" }, + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "cssselect2", marker = "platform_python_implementation != 'PyPy'" }, + { name = "fonttools", extra = ["woff"], marker = "platform_python_implementation != 'PyPy'" }, + { name = "pillow", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pydyf", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pyphen", marker = "platform_python_implementation != 'PyPy'" }, + { name = "tinycss2", marker = "platform_python_implementation != 'PyPy'" }, + { name = "tinyhtml5", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/59/53/dcc3885c2f7a47faa45f6b8b801412f5f9e055173a52801ef01c09943c5a/weasyprint-69.0.tar.gz", hash = "sha256:a7a32f39ca16bd82ef11de99c92ea4b5f14951c9033af035e451ce4f4ee0a88c", size = 1549834, upload-time = "2026-06-02T14:42:17.765Z" } wheels = [ @@ -6270,7 +6720,7 @@ name = "webob" version = "1.8.10" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "legacy-cgi", marker = "python_full_version >= '3.13'" }, + { name = "legacy-cgi", marker = "python_full_version >= '3.13' and platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/58/f9/974eafebfd0bd442b8848899fe7d30675c93f750c313e1a6fe61acbde1e3/webob-1.8.10.tar.gz", hash = "sha256:1c963a11f307bc3f624fbab9dde737701eae255f32981b7a5486a88db1767c2b", size = 280796, upload-time = "2026-06-02T19:56:47.268Z" } wheels = [ @@ -6324,9 +6774,9 @@ name = "webtest" version = "3.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "beautifulsoup4" }, - { name = "waitress" }, - { name = "webob" }, + { name = "beautifulsoup4", marker = "platform_python_implementation != 'PyPy'" }, + { name = "waitress", marker = "platform_python_implementation != 'PyPy'" }, + { name = "webob", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/26/c8/8ffba1782700eb06e9b9169156698c6ba95c05b66cda3fc9e025b6b3b649/WebTest-3.0.0.tar.gz", hash = "sha256:54bd969725838d9861a9fa27f8d971f79d275d94ae255f5c501f53bb6d9929eb", size = 75871, upload-time = "2021-08-19T12:40:07.234Z" } wheels = [ @@ -6338,7 +6788,7 @@ name = "werkzeug" version = "3.1.8" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markupsafe" }, + { name = "markupsafe", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } wheels = [ @@ -6350,8 +6800,8 @@ name = "wosclient" version = "0.1.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click" }, - { name = "requests" }, + { name = "click", marker = "platform_python_implementation != 'PyPy'" }, + { name = "requests", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/37/a1/5d170a12144cf75f06ec01f6ddd991b38f79ca8f018ae9831723207cc62d/wosclient-0.1.5.tar.gz", hash = "sha256:ca8e3c40cac64fdb22c3b0627ef8b9d115a38ffea237fc23c4197d34f0a4f384", size = 12655, upload-time = "2018-04-22T14:37:34.041Z" } wheels = [ @@ -6422,7 +6872,7 @@ name = "wsgi-intercept" version = "1.13.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "six" }, + { name = "six", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/4a/ed/28202fddeff86871b8a8d1fbb32878fe9df5592d5fd0fbac4ab81764202a/wsgi_intercept-1.13.1.tar.gz", hash = "sha256:79bd6c639ab11f7eabe5d18ad9a20f62ee82a1c435124a876cffd8038f6c24c1", size = 29288, upload-time = "2024-08-08T08:16:35.519Z" } wheels = [ @@ -6440,15 +6890,15 @@ name = "xhtml2pdf" version = "0.2.17" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "arabic-reshaper" }, - { name = "html5lib" }, - { name = "pillow" }, - { name = "pyhanko" }, - { name = "pyhanko-certvalidator" }, - { name = "pypdf" }, - { name = "python-bidi" }, - { name = "reportlab" }, - { name = "svglib" }, + { name = "arabic-reshaper", marker = "platform_python_implementation != 'PyPy'" }, + { name = "html5lib", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pillow", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pyhanko", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pyhanko-certvalidator", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pypdf", marker = "platform_python_implementation != 'PyPy'" }, + { name = "python-bidi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "reportlab", marker = "platform_python_implementation != 'PyPy'" }, + { name = "svglib", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/da/9a/3b29831d8617ecbcf0b0aaa2b3e1b24f3fd1bbd204678ae86e9fee2f4239/xhtml2pdf-0.2.17.tar.gz", hash = "sha256:09ddbc31aa0e38a16f2f3cb73be89af5f7c968c17a564afdd685d280e39c526d", size = 139727, upload-time = "2025-02-23T23:17:02.484Z" } wheels = [ From 13152e1d8d71c654d94c42b8fcd95a5f58a33001 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sun, 6 Sep 2026 14:24:19 +0200 Subject: [PATCH 08/29] =?UTF-8?q?docs(spec):=20zmierzona=20delta=20zale?= =?UTF-8?q?=C5=BCno=C5=9Bci=20=E2=80=94=2021=20pakiet=C3=B3w,=20nie=20dzie?= =?UTF-8?q?si=C4=99=C4=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU --- ...6-09-05-mcp-hostowany-w-serwisie-design.md | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-09-05-mcp-hostowany-w-serwisie-design.md b/docs/superpowers/specs/2026-09-05-mcp-hostowany-w-serwisie-design.md index cd703f3ab..29279091b 100644 --- a/docs/superpowers/specs/2026-09-05-mcp-hostowany-w-serwisie-design.md +++ b/docs/superpowers/specs/2026-09-05-mcp-hostowany-w-serwisie-design.md @@ -676,8 +676,10 @@ link do `bpp-mcp` dla stdio. Adresy przez `build_absolute_uri`. Teksty przez ## 9. Zależności i obraz -Dochodzi `bpp-mcp>=0.4,<0.5`. **Delta policzona** przeciwko `uv.lock` BPP — -dziesięć nowych pakietów: +Dochodzi `bpp-mcp>=0.4,<0.5`. **Zmierzone po faktycznym `uv lock` +(Task 1 planu): 21 nowych pakietów, nie dziesięć.** Wcześniejsze oszacowanie +liczyło tylko zależności bezpośrednie i było zaniżone ponad dwukrotnie — +poniżej stan rzeczywisty: | Nowe | Uwaga | |---|---| @@ -686,8 +688,24 @@ dziesięć nowych pakietów: | `pydantic` | kompilowana, spora | | `opentelemetry-api`, `jsonschema`, `mcp-types`, `python-multipart`, `sse-starlette`, `starlette`, `typing-inspection` | | +Zależności przechodnie, których pierwsze oszacowanie nie objęło: +`annotated-types`, `httpcore`, `httpcore2`, `httpx2-jsfetch`, `jsonschema`, +`jsonschema-specifications`, **`pydantic-core`**, `referencing`, **`rpds-py`**, +`truststore`, `typing-inspection`. + Już obecne: `anyio` (4.11.0), `pyjwt`, `uvicorn`. +**Dwa ustalenia, które podnoszą wagę ryzyka z §15:** + +1. **`pydantic-core` i `rpds-py` to kompilowane rozszerzenia Rust** — osobne + koła per platforma. To realny wzrost rozmiaru obrazu i osobna powierzchnia + CVE dla bramki Trivy, której pierwsze oszacowanie w ogóle nie przewidywało. +2. **Dwa równoległe stosy HTTP w drzewie**: `bpp-mcp` zależy od `httpx`, a + `mcp` 2.x od `httpx2` — każdy z własnym `httpcore`. Dochodzi też + `httpx2-jsfetch` z markerem `sys_platform == 'emscripten'`, czyli kod WASM + nieużywany w kontenerze Linux, ale obecny w locku. To wzmacnia argument za + przejściem `bpp-mcp` na `httpx2` (§13, §15.5) — dziś wozimy oba. + **Kolizji `starlette` nie ma** — `channels[daphne]` ciągnie `asgiref` i `daphne`, nie `starlette`. To zamyka §15.2. From 96e2073edc173360b9466b6f156b633da4a02ff3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sun, 6 Sep 2026 14:31:16 +0200 Subject: [PATCH 09/29] =?UTF-8?q?feat(mcp=5Fserver):=20StartMcp=20?= =?UTF-8?q?=E2=80=94=20lifespan=20MCP=20w=20osobnym=20zadaniu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU --- src/mcp_server/start.py | 67 ++++++++++++++++++++++ src/mcp_server/tests/test_start.py | 89 ++++++++++++++++++++++++++++++ src/mcp_server/tests/utils.py | 63 +++++++++++++++++++++ 3 files changed, 219 insertions(+) create mode 100644 src/mcp_server/start.py create mode 100644 src/mcp_server/tests/test_start.py create mode 100644 src/mcp_server/tests/utils.py diff --git a/src/mcp_server/start.py b/src/mcp_server/start.py new file mode 100644 index 000000000..5d48cdc77 --- /dev/null +++ b/src/mcp_server/start.py @@ -0,0 +1,67 @@ +"""Jednorazowe wejście w lifespan aplikacji MCP, w osobnym zadaniu. + +Menedżer sesji MCP zakłada grupę zadań anyio (``session_manager.run()``). +Zadanie, które w nią wchodzi, staje się jej HOST TASKIEM — a to ma dwie +konsekwencje, przez które nie wolno robić tego z zadania obsługującego +żądanie (spec §5.1): + +* jeżeli w tym zadaniu jest aktywny inny cancel scope (nasz budżet czasu), + wyjście z niego rzuci ``RuntimeError: Attempted to exit a cancel scope that + isn't the current task's current cancel scope`` — a grupa jest już wtedy + oznaczona jako wystartowana, więc padają też WSZYSTKIE kolejne żądania; +* anulowanie host taska (timeout, ``application_close_timeout`` Daphne) + zatruwa grupę nieodwracalnie, bo ``run()`` wchodzi się raz na instancję. + +Dlatego host taskiem jest zadanie tła, żyjące tyle, co proces. +""" + +from __future__ import annotations + +import asyncio + + +class StartMcp: + """Trzyma lifespan aplikacji MCP przy życiu przez cały czas życia procesu.""" + + def __init__(self, aplikacja) -> None: + self._aplikacja = aplikacja + self._gotowe = asyncio.Event() + self._zadanie: asyncio.Task | None = None + self._blad: BaseException | None = None + + @property + def wystartowany(self) -> bool: + """Czy lifespan został pomyślnie otwarty.""" + return self._gotowe.is_set() and self._blad is None + + @property + def zywy(self) -> bool: + """Czy zadanie trzymające grupę nadal działa (healthcheck, spec §10.3).""" + if self._blad is not None: + return False + return self._zadanie is not None and not self._zadanie.done() + + async def zapewnij(self) -> None: + """Wystartuj menedżera sesji, jeśli jeszcze nie działa, i poczekaj. + + Idempotentne. Wołane z ``LifespanMcp`` (uvicorn) albo z pierwszego + żądania (Daphne — nie implementuje protokołu lifespan). + """ + if self._zadanie is None: + # create_task nie ma punktu przerwania między testem a przypisaniem + # w tej samej pętli, więc blokada nie jest potrzebna. + self._zadanie = asyncio.get_running_loop().create_task(self._trzymaj()) + await self._gotowe.wait() + if self._blad is not None: + raise RuntimeError("Menedżer sesji MCP nie wstał") from self._blad + + async def _trzymaj(self) -> None: + """Host task grupy zadań — czeka w nieskończoność.""" + try: + async with self._aplikacja.router.lifespan_context(self._aplikacja): + self._gotowe.set() + await asyncio.Event().wait() + except BaseException as exc: + self._blad = exc + self._gotowe.set() + raise diff --git a/src/mcp_server/tests/test_start.py b/src/mcp_server/tests/test_start.py new file mode 100644 index 000000000..0c161d798 --- /dev/null +++ b/src/mcp_server/tests/test_start.py @@ -0,0 +1,89 @@ +"""StartMcp: wejście w lifespan aplikacji MCP dokładnie raz, w osobnym zadaniu. + +Kluczowa regresja (spec §5.1, bloker BL-3): wejście w grupę zadań anyio +z zadania ŻĄDANIA czyni je host taskiem; aktywny w tym zadaniu cancel scope +rzuca wtedy RuntimeError przy wyjściu, a grupa zostaje trwale zepsuta. +""" + +import asyncio +from contextlib import asynccontextmanager + +import anyio +import pytest + +from mcp_server.start import StartMcp +from mcp_server.tests.utils import uruchom + + +class _AplikacjaZLifespanem: + """Namiastka aplikacji Starlette: liczy wejścia w lifespan.""" + + def __init__(self, blad=None): + self.wejscia = 0 + self._blad = blad + self.router = self + + @asynccontextmanager + async def lifespan_context(self, _app): + if self._blad is not None: + raise self._blad + self.wejscia += 1 + yield + + +def test_zapewnij_wchodzi_dokladnie_raz(): + app = _AplikacjaZLifespanem() + start = StartMcp(app) + + async def scenariusz(): + await start.zapewnij() + await start.zapewnij() + await start.zapewnij() + return start.wystartowany + + assert uruchom(scenariusz) is True + assert app.wejscia == 1 + + +def test_zapewnij_pod_aktywnym_cancel_scope_nie_psuje_zadania(): + """REGRESJA BL-3: wywołanie z zadania, w którym trwa fail_after, nie może + ani wywalić tego zadania, ani zepsuć kolejnych wywołań.""" + app = _AplikacjaZLifespanem() + start = StartMcp(app) + + async def scenariusz(): + with anyio.fail_after(5): + await start.zapewnij() + # wyjście z fail_after musi się udać — i drugie wywołanie też + await start.zapewnij() + return app.wejscia + + assert uruchom(scenariusz) == 1 + + +def test_blad_startu_jest_podnoszony_i_zapamietany(): + app = _AplikacjaZLifespanem(blad=RuntimeError("brak Redisa")) + start = StartMcp(app) + + async def scenariusz(): + with pytest.raises(RuntimeError): + await start.zapewnij() + return start.zywy + + assert uruchom(scenariusz) is False + + +def test_zadanie_przezywa_zadanie_ktore_je_utworzylo(): + """Grupa zadań ma żyć dłużej niż żądanie, z którego wystartowała.""" + app = _AplikacjaZLifespanem() + start = StartMcp(app) + + async def scenariusz(): + async def udaje_zadanie(): + await start.zapewnij() + + await asyncio.create_task(udaje_zadanie()) + await asyncio.sleep(0.05) + return start.zywy + + assert uruchom(scenariusz) is True diff --git a/src/mcp_server/tests/utils.py b/src/mcp_server/tests/utils.py new file mode 100644 index 000000000..83c04033b --- /dev/null +++ b/src/mcp_server/tests/utils.py @@ -0,0 +1,63 @@ +"""Pomocniki testowe warstwy ASGI. + +Repo nie ma ``pytest-asyncio``, a ``async_to_sync`` rzuca, gdy w bieżącym +wątku biegnie już pętla (flaky zależny od kolejności testów — patrz +``django_bpp/tests/test_asgi_notifications.py``). Odpalamy więc każdą korutynę +przez ``asyncio.run`` w dedykowanym wątku ze świeżą pętlą. +""" + +import asyncio +import json +from concurrent.futures import ThreadPoolExecutor + + +def uruchom(coro_factory): + """Odpal fabrykę korutyn w osobnym wątku i zwróć jej wynik. + + Przyjmujemy FABRYKĘ, nie korutynę: korutyna utworzona w wątku wołającym + byłaby związana z jego (nieistniejącą) pętlą. + """ + with ThreadPoolExecutor(max_workers=1) as ex: + return ex.submit(lambda: asyncio.run(coro_factory())).result() + + +def zbuduj_scope(sciezka, metoda="POST", host="bpp.example.test", naglowki=None): + """Minimalny scope HTTP/ASGI do wołania aplikacji wprost.""" + bazowe = [(b"host", host.encode())] + for k, v in (naglowki or {}).items(): + bazowe.append((k.lower().encode(), v.encode())) + return { + "type": "http", + "asgi": {"version": "3.0", "spec_version": "2.3"}, + "http_version": "1.1", + "method": metoda, + "scheme": "http", + "path": sciezka, + "raw_path": sciezka.encode(), + "query_string": b"", + "root_path": "", + "headers": bazowe, + "client": ("203.0.113.7", 54321), + "server": (host, 80), + } + + +async def wywolaj(app, scope, cialo=None): + """Zawołaj aplikację ASGI i zwróć ``(status, naglowki, tekst)``.""" + raw = json.dumps(cialo).encode() if cialo is not None else b"" + do_odebrania = [{"type": "http.request", "body": raw, "more_body": False}] + wyslane = [] + + async def receive(): + return do_odebrania.pop(0) if do_odebrania else {"type": "http.disconnect"} + + async def send(msg): + wyslane.append(msg) + + await app(scope, receive, send) + start = next((m for m in wyslane if m["type"] == "http.response.start"), None) + tresc = b"".join( + m.get("body", b"") for m in wyslane if m["type"] == "http.response.body" + ) + naglowki = {k.decode(): v.decode() for k, v in (start or {}).get("headers", [])} + return (start or {}).get("status"), naglowki, tresc.decode(errors="replace") From 98da8e409ad258fb228bc8e62f3c6b24f2e35fcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sun, 6 Sep 2026 14:56:21 +0200 Subject: [PATCH 10/29] =?UTF-8?q?fix(mcp=5Fserver):=20atrapa=20lifespan=20?= =?UTF-8?q?z=20realn=C4=85=20task=20group;=20test=20wsp=C3=B3=C5=82bie?= =?UTF-8?q?=C5=BCno=C5=9Bci?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU --- src/mcp_server/tests/test_start.py | 31 ++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/src/mcp_server/tests/test_start.py b/src/mcp_server/tests/test_start.py index 0c161d798..1ed34a55d 100644 --- a/src/mcp_server/tests/test_start.py +++ b/src/mcp_server/tests/test_start.py @@ -16,7 +16,17 @@ class _AplikacjaZLifespanem: - """Namiastka aplikacji Starlette: liczy wejścia w lifespan.""" + """Namiastka aplikacji Starlette: liczy wejścia w lifespan. + + ``lifespan_context`` otwiera PRAWDZIWĄ ``anyio.create_task_group()`` — + tak jak realnie robi to ``session_manager.run()`` z SDK MCP. Bez tego + atrapa nie odwzorowuje mechanizmu z BL-3: prosty ``@asynccontextmanager`` + bez grupy zadań nie zostawia na stosie zadania żadnego cancel scope'u, + więc naiwna (błędna) implementacja ``StartMcp`` nie psuje się przez + ``RuntimeError`` opisany w docstringu ``start.py`` — tylko wisi, aż + zewnętrzny ``fail_after`` ubije ją po timeout (inny, dużo wolniejszy + i mniej precyzyjny sposób zawodzenia testu). + """ def __init__(self, blad=None): self.wejscia = 0 @@ -28,7 +38,8 @@ async def lifespan_context(self, _app): if self._blad is not None: raise self._blad self.wejscia += 1 - yield + async with anyio.create_task_group(): + yield def test_zapewnij_wchodzi_dokladnie_raz(): @@ -87,3 +98,19 @@ async def udaje_zadanie(): return start.zywy assert uruchom(scenariusz) is True + + +def test_wiele_rownoleglych_zapewnij_wchodzi_raz(): + """Brak punktu przerwania między ``if self._zadanie is None`` a + ``create_task`` (komentarz w ``start.py``) ma znaczenie tylko wtedy, gdy + wiele wywołań ``zapewnij()`` rusza NAPRAWDĘ współbieżnie w tej samej + pętli — sekwencyjne `await` (jak w testach wyżej) tego nie sprawdza. + Tu odpalamy 50 wywołań przez ``asyncio.gather`` na raz.""" + app = _AplikacjaZLifespanem() + start = StartMcp(app) + + async def scenariusz(): + await asyncio.gather(*(start.zapewnij() for _ in range(50))) + return app.wejscia + + assert uruchom(scenariusz) == 1 From 6d8467ebdb5b66c567592bf06de0ee808c6564cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sun, 6 Sep 2026 15:22:30 +0200 Subject: [PATCH 11/29] =?UTF-8?q?docs(mcp=5Fserver):=20sprostuj=20docstrin?= =?UTF-8?q?g=20StartMcp=20=E2=80=94=20dwa=20objawy=20b=C5=82=C4=99du,=20ni?= =?UTF-8?q?e=20jeden?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU --- src/mcp_server/start.py | 26 +++++++++++++++++--------- src/mcp_server/tests/test_start.py | 5 ++++- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/src/mcp_server/start.py b/src/mcp_server/start.py index 5d48cdc77..e7e1e7661 100644 --- a/src/mcp_server/start.py +++ b/src/mcp_server/start.py @@ -1,16 +1,24 @@ """Jednorazowe wejście w lifespan aplikacji MCP, w osobnym zadaniu. Menedżer sesji MCP zakłada grupę zadań anyio (``session_manager.run()``). -Zadanie, które w nią wchodzi, staje się jej HOST TASKIEM — a to ma dwie -konsekwencje, przez które nie wolno robić tego z zadania obsługującego -żądanie (spec §5.1): +Zadanie, które w nią wchodzi, staje się jej HOST TASKIEM i nigdy z niej nie +wraca (grupa żyje, dopóki host task działa) — a to jest fatalne, gdy tym +zadaniem jest zadanie obsługujące żądanie (spec §5.1). Objaw zależy od tego, +JAK błędnie to zrobiono — nie ma jednego wyjątku do złapania: -* jeżeli w tym zadaniu jest aktywny inny cancel scope (nasz budżet czasu), - wyjście z niego rzuci ``RuntimeError: Attempted to exit a cancel scope that - isn't the current task's current cancel scope`` — a grupa jest już wtedy - oznaczona jako wystartowana, więc padają też WSZYSTKIE kolejne żądania; -* anulowanie host taska (timeout, ``application_close_timeout`` Daphne) - zatruwa grupę nieodwracalnie, bo ``run()`` wchodzi się raz na instancję. +* jeśli w tym zadaniu jest aktywny inny cancel scope (nasz budżet czasu) + i porządek LIFO scope'ów zostanie naruszony, wyjście z tamtego scope'u + rzuci ``RuntimeError: Attempted to exit a cancel scope that isn't the + current task's current cancel scope``; +* jeśli zagnieżdżenie scope'ów zostaje poprawne (np. wejście w grupę jest + w pełni ustrukturyzowane), zadanie żądania po prostu **nigdy nie wraca** + — wisi, trzymając grupę, aż zewnętrzny budżet czasu (``fail_after``, + timeout serwera) je anuluje. + +W obu przypadkach grupa jest już oznaczona jako wystartowana, więc padają +też WSZYSTKIE kolejne żądania — a anulowanie host taska (timeout, +``application_close_timeout`` Daphne) zatruwa ją nieodwracalnie, bo +``run()`` wchodzi się raz na instancję. Dlatego host taskiem jest zadanie tła, żyjące tyle, co proces. """ diff --git a/src/mcp_server/tests/test_start.py b/src/mcp_server/tests/test_start.py index 1ed34a55d..552f1ca8d 100644 --- a/src/mcp_server/tests/test_start.py +++ b/src/mcp_server/tests/test_start.py @@ -63,7 +63,10 @@ def test_zapewnij_pod_aktywnym_cancel_scope_nie_psuje_zadania(): start = StartMcp(app) async def scenariusz(): - with anyio.fail_after(5): + # 1 s wystarczy: przy poprawnej implementacji test i tak kończy się + # natychmiast, a przy regresji (zadanie wisi, trzymając grupę) budżet + # ogranicza karę CI do 1 s zamiast do domyślnych tu 5 s. + with anyio.fail_after(1): await start.zapewnij() # wyjście z fail_after musi się udać — i drugie wywołanie też await start.zapewnij() From c08edabbea057c0a9bc2badda50494248ce9bc2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sun, 6 Sep 2026 15:30:05 +0200 Subject: [PATCH 12/29] =?UTF-8?q?feat(mcp=5Fserver):=20kontekst=20=C5=BC?= =?UTF-8?q?=C4=85dania=20+=20KlientScope=20z=20prawdziwym=20IP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU --- src/mcp_server/klient.py | 25 ++++++++++++ src/mcp_server/kontekst.py | 44 +++++++++++++++++++++ src/mcp_server/tests/test_klient.py | 59 +++++++++++++++++++++++++++++ 3 files changed, 128 insertions(+) create mode 100644 src/mcp_server/klient.py create mode 100644 src/mcp_server/kontekst.py create mode 100644 src/mcp_server/tests/test_klient.py diff --git a/src/mcp_server/klient.py b/src/mcp_server/klient.py new file mode 100644 index 000000000..54701dcbe --- /dev/null +++ b/src/mcp_server/klient.py @@ -0,0 +1,25 @@ +"""Klient wołający własną aplikację Django w procesie.""" + +from __future__ import annotations + +from mcp_server.kontekst import biezace + + +class KlientScope: + """Nadpisuje ``scope["client"]`` prawdziwym IP pytającego. + + ``httpx.ASGITransport`` buduje scope wewnętrznego żądania sam i wpisuje + tam ``("127.0.0.1", 123)``. Warstwa stojąca PRZED aplikacją MCP nie ma jak + tego dosięgnąć — to jedyne miejsce, w którym da się to poprawić. Bez tego + ``SearchAnonThrottle`` (liczy po IP) zlewa wszystkich anonimów do jednego + kubełka (spec §7.3). + """ + + def __init__(self, aplikacja) -> None: + self._aplikacja = aplikacja + + async def __call__(self, scope, receive, send): + dane = biezace() # fail-closed + scope = dict(scope) + scope["client"] = (dane.ip, 0) + await self._aplikacja(scope, receive, send) diff --git a/src/mcp_server/kontekst.py b/src/mcp_server/kontekst.py new file mode 100644 index 000000000..671b0f3c1 --- /dev/null +++ b/src/mcp_server/kontekst.py @@ -0,0 +1,44 @@ +"""Dane zewnętrznego żądania, przenoszone do warstwy wykonującej narzędzia. + +Klient MCP żyje w lifespanie (jeden na proces), a host, protokół, IP i token +zależą od KONKRETNEGO żądania. Przenosimy je ContextVarem — MCP SDK celowo +kopiuje kontekst przez granicę zadań (transport zapisuje ``copy_context()`` +nadawcy, dispatcher odtwarza go przy uruchamianiu handlera), więc wartość +ustawiona w warstwie ASGI jest widoczna w kodzie narzędzia. +""" + +from __future__ import annotations + +from contextvars import ContextVar +from dataclasses import dataclass + + +@dataclass(frozen=True) +class DaneZadania: + """Wszystko, czego wewnętrzne żądanie nie ma skąd wziąć samo.""" + + host: str + scheme: str + ip: str + bearer: str | None + deadline: float | None # monotoniczny znacznik końca budżetu + + +dane_zadania: ContextVar[DaneZadania | None] = ContextVar( + "mcp_dane_zadania", default=None +) + + +def biezace() -> DaneZadania: + """Zwróć dane bieżącego żądania albo rzuć — NIGDY nie zgaduj. + + Fallback na wartości domyślne oznaczałby ciche żądanie z nieznanego hosta, + czyli obejście bramki API i wyciek między uczelniami (spec §7.2). + """ + dane = dane_zadania.get() + if dane is None: + raise RuntimeError( + "Brak kontekstu żądania MCP — warstwa KontekstMcp nie została " + "uruchomiona albo ContextVar został zresetowany za wcześnie." + ) + return dane diff --git a/src/mcp_server/tests/test_klient.py b/src/mcp_server/tests/test_klient.py new file mode 100644 index 000000000..86896d40f --- /dev/null +++ b/src/mcp_server/tests/test_klient.py @@ -0,0 +1,59 @@ +"""KlientScope: wewnętrzne żądanie musi nieść PRAWDZIWE IP pytającego. + +httpx.ASGITransport buduje scope sam i wpisuje tam własne ``client`` +(127.0.0.1). Bez podmiany wszyscy anonimowi użytkownicy MCP dzieliliby jeden +kubełek throttlingu DRF — jeden klient DoS-owałby wszystkich (spec §7.3). +""" + +import pytest + +from mcp_server.klient import KlientScope +from mcp_server.kontekst import DaneZadania, dane_zadania +from mcp_server.tests.utils import uruchom + + +async def _echo_client(scope, receive, send): + await send({"type": "http.response.start", "status": 200, "headers": []}) + await send({"type": "http.response.body", "body": str(scope["client"]).encode()}) + + +def test_klient_scope_wstawia_ip_z_kontekstu(): + app = KlientScope(_echo_client) + + async def scenariusz(): + zeton = dane_zadania.set( + DaneZadania( + host="bpp.example.test", + scheme="https", + ip="198.51.100.9", + bearer=None, + deadline=None, + ) + ) + try: + wyslane = [] + + async def send(m): + wyslane.append(m) + + async def receive(): + return {"type": "http.request", "body": b"", "more_body": False} + + await app({"type": "http", "client": ("127.0.0.1", 123)}, receive, send) + return b"".join(m.get("body", b"") for m in wyslane).decode() + finally: + dane_zadania.reset(zeton) + + assert "198.51.100.9" in uruchom(scenariusz) + + +def test_klient_scope_bez_kontekstu_rzuca(): + """FAIL-CLOSED: brak kontekstu to błąd programisty, nie powód do cichego + fallbacku na 127.0.0.1 (spec §5.2).""" + app = KlientScope(_echo_client) + + async def scenariusz(): + with pytest.raises(RuntimeError): + await app({"type": "http", "client": ("127.0.0.1", 1)}, None, None) + + uruchom(scenariusz) From 2dc8a5a5c5948c339d09d8b7680e080ed01edc25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sun, 6 Sep 2026 15:46:06 +0200 Subject: [PATCH 13/29] =?UTF-8?q?feat(mcp=5Fserver):=20BppClientInProcess?= =?UTF-8?q?=20per=20=C5=BC=C4=85danie=20z=20bud=C5=BCetem=20czasu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU --- src/mcp_server/klient.py | 65 ++++++++++++++++++++++++++++ src/mcp_server/tests/test_klient.py | 67 ++++++++++++++++++++++++++++- 2 files changed, 131 insertions(+), 1 deletion(-) diff --git a/src/mcp_server/klient.py b/src/mcp_server/klient.py index 54701dcbe..3396324a9 100644 --- a/src/mcp_server/klient.py +++ b/src/mcp_server/klient.py @@ -2,6 +2,12 @@ from __future__ import annotations +import time + +import httpx +from bpp_mcp.client import BppClient, BppError, TrybAuth +from bpp_mcp.config import Config + from mcp_server.kontekst import biezace @@ -23,3 +29,62 @@ async def __call__(self, scope, receive, send): scope = dict(scope) scope["client"] = (dane.ip, 0) await self._aplikacja(scope, receive, send) + + +class BppClientInProcess(BppClient): + """``BppClient`` wołający własną aplikację Django, bez gniazda TCP. + + Tworzony PER ŻĄDANIE (spec D9): ``BppClient`` zapamiętuje ``api_root`` + w ``__init__``, a adres zależy od nagłówka ``Host`` konkretnego żądania. + Przy okazji rozwiązuje to izolację cache (świeży klient = świeży słownik) + i sprowadza semafor do zasięgu jednego wywołania narzędzia. + """ + + def __init__(self, *, transport=None) -> None: + dane = biezace() + if transport is None: + from django_bpp.asgi import django_asgi_app + + transport = httpx.ASGITransport( + app=KlientScope(django_asgi_app), + # 500 z aplikacji ma wrócić jako BppNetworkError z czytelnym + # komunikatem, a nie jako surowy traceback w wyniku narzędzia. + raise_app_exceptions=False, + ) + super().__init__( + Config(base_url=f"{dane.scheme}://{dane.host}", transport="stdio"), + transport=transport, + tryb_auth=TrybAuth.W_PROCESIE, + max_retries=0, # nie ma sieci, która mogłaby zamigotać + ) + # Przekierowania WYŁĄCZONE (spec D12): SECURE_SSL_REDIRECT po cichu + # podwajałby każde żądanie, a FirstRunWizardMiddleware przekierowuje + # /api/v1/ na HTML kreatora, co kończyłoby się ValueError w resp.json(). + self._client.follow_redirects = False + + async def _request(self, full, *, retry_5xx=True): + """Egzekwuj budżet czasu PRZED wykonaniem żądania. + + Limit wokół aplikacji MCP nie działa (spec §5.2, BL-2): handler biegnie + w zadaniu grupy menedżera, więc anulowanie zadania żądania odcina tylko + odpowiedź. Sprawdzenie musi być tutaj, gdzie faktycznie wykonuje się + praca. + """ + dane = biezace() + if dane.deadline is not None and time.monotonic() >= dane.deadline: + raise BppError( + "Przekroczono budżet czasu wywołania narzędzia — zawęź " + "zapytanie albo poproś o mniej danych." + ) + if not str(full.path).startswith("/api/v1/"): + raise BppError(f"Ścieżka spoza /api/v1/ jest niedozwolona: {full.path}") + if full.host and full.host != dane.host: + raise BppError( + f"Host spoza bieżącego żądania jest niedozwolony: {full.host}" + ) + return await super()._request(full, retry_5xx=retry_5xx) + + +def zbuduj_klienta() -> BppClientInProcess: + """Zbuduj klienta dla bieżącego żądania.""" + return BppClientInProcess() diff --git a/src/mcp_server/tests/test_klient.py b/src/mcp_server/tests/test_klient.py index 86896d40f..0385dc667 100644 --- a/src/mcp_server/tests/test_klient.py +++ b/src/mcp_server/tests/test_klient.py @@ -5,9 +5,13 @@ kubełek throttlingu DRF — jeden klient DoS-owałby wszystkich (spec §7.3). """ +import time + +import httpx import pytest +from bpp_mcp.client import BppError -from mcp_server.klient import KlientScope +from mcp_server.klient import BppClientInProcess, KlientScope, zbuduj_klienta from mcp_server.kontekst import DaneZadania, dane_zadania from mcp_server.tests.utils import uruchom @@ -57,3 +61,64 @@ async def scenariusz(): await app({"type": "http", "client": ("127.0.0.1", 1)}, None, None) uruchom(scenariusz) + + +def _dane(deadline=None, host="bpp.example.test"): + return DaneZadania( + host=host, scheme="https", ip="198.51.100.9", bearer=None, deadline=deadline + ) + + +def test_api_root_bierze_sie_z_hosta_zadania(): + """Sedno D9: adres API zależy od hosta KONKRETNEGO żądania (spec §5.3).""" + + async def scenariusz(): + zeton = dane_zadania.set(_dane(host="bpp.umlub.pl")) + try: + klient = zbuduj_klienta() + return str(klient._full_url("autor/")) + finally: + dane_zadania.reset(zeton) + + assert uruchom(scenariusz) == "https://bpp.umlub.pl/api/v1/autor/" + + +def test_dwa_zadania_dwa_rozne_adresy(): + async def scenariusz(): + adresy = [] + for host in ("bpp.a.test", "bpp.b.test"): + zeton = dane_zadania.set(_dane(host=host)) + try: + adresy.append(str(zbuduj_klienta()._full_url("autor/"))) + finally: + dane_zadania.reset(zeton) + return adresy + + a, b = uruchom(scenariusz) + assert a != b + assert "bpp.a.test" in a and "bpp.b.test" in b + + +def test_przekroczony_budzet_zatrzymuje_zadania(): + """REGRESJA BL-2: po wyczerpaniu budżetu klient NIE wykonuje kolejnych + żądań — limit ma zatrzymywać pracę, nie tylko przestać na nią czekać.""" + wywolania = [] + + def handler(request): + wywolania.append(request) + return httpx.Response(200, json={"ok": 1}) + + async def scenariusz(): + zeton = dane_zadania.set(_dane(deadline=time.monotonic() - 1)) + try: + klient = BppClientInProcess(transport=httpx.MockTransport(handler)) + # BppError konkretnie, nie goły Exception (ruff B017) — to jest + # dokładnie ten wyjątek, który _request ma podnieść przy + # przekroczonym budżecie. + with pytest.raises(BppError): + await klient.get_json("autor/1/") + finally: + dane_zadania.reset(zeton) + + uruchom(scenariusz) + assert wywolania == [] From b49220e079bc777fd7e54ac7cc5ea3c71c8b1c9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sun, 6 Sep 2026 15:50:44 +0200 Subject: [PATCH 14/29] docs(plan): mostek bearera do ContextVara pakietu bpp_mcp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU --- .../2026-09-06-mcp-hostowany-w-serwisie.md | 61 +++++++++++++++++-- 1 file changed, 55 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/plans/2026-09-06-mcp-hostowany-w-serwisie.md b/docs/superpowers/plans/2026-09-06-mcp-hostowany-w-serwisie.md index 44318053e..7efbb9440 100644 --- a/docs/superpowers/plans/2026-09-06-mcp-hostowany-w-serwisie.md +++ b/docs/superpowers/plans/2026-09-06-mcp-hostowany-w-serwisie.md @@ -1107,6 +1107,46 @@ def test_get_mcp_z_przegladarki_przekierowuje_na_slash(): assert status == 307 and naglowki["location"] == "/mcp/" +def test_bearer_trafia_do_contextvara_pakietu(): + """`BppClient` czyta token z WŁASNEGO ContextVara pakietu bpp_mcp, nie + z naszego. Bez tego mostka zalogowany użytkownik po cichu leciałby + anonimowo, a warstwa OAuth byłaby dekoracją.""" + from bpp_mcp.auth import current_bearer + + widziany = [] + + async def _podglada(scope, receive, send): + widziany.append(current_bearer()) + await send({"type": "http.response.start", "status": 200, "headers": []}) + await send({"type": "http.response.body", "body": b""}) + + router = RouterHttp(_podglada, _django, StartMcp(_AtrapaLifespanu())) + scope = zbuduj_scope("/mcp", naglowki={"authorization": "Bearer TOKEN-XYZ"}) + uruchom(lambda: wywolaj(router, scope)) + assert widziany == ["TOKEN-XYZ"] + + +def test_bearer_nie_wycieka_do_nastepnego_zadania(): + """Po zakończeniu żądania ContextVar pakietu musi być wyczyszczony.""" + from bpp_mcp.auth import current_bearer + + widziany = [] + + async def _podglada(scope, receive, send): + widziany.append(current_bearer()) + await send({"type": "http.response.start", "status": 200, "headers": []}) + await send({"type": "http.response.body", "body": b""}) + + async def scenariusz(): + router = RouterHttp(_podglada, _django, StartMcp(_AtrapaLifespanu())) + z_tokenem = zbuduj_scope("/mcp", naglowki={"authorization": "Bearer T1"}) + await wywolaj(router, z_tokenem) + await wywolaj(router, zbuduj_scope("/mcp")) + + uruchom(scenariusz) + assert widziany == ["T1", None] + + def test_lifespan_startup_complete(): start = StartMcp(_AtrapaLifespanu()) app = LifespanMcp(start) @@ -1147,6 +1187,8 @@ import time from django.conf import settings +from bpp_mcp.auth import set_current_bearer + from django_bpp.client_ip import get_client_ip from mcp_server.auth import BramkaBearera from mcp_server.kontekst import DaneZadania, dane_zadania @@ -1215,8 +1257,15 @@ class RouterHttp: await self._niedostepny(send) return - scope = self._z_kontekstem(scope) - zeton = dane_zadania.set(self._dane(scope)) + dane = self._dane(scope) + zeton = dane_zadania.set(dane) + # DWA ContextVary, nie jeden. `BppClient._auth_kwargs` czyta token + # z WŁASNEGO ContextVara pakietu bpp_mcp (`bpp_mcp.auth`), nie + # z naszego. Bez tej linii `DaneZadania.bearer` nie ma żadnej drogi + # do żądania wychodzącego i KAŻDE wywołanie leci anonimowo — czyli + # zalogowany użytkownik po cichu traci dostęp do swoich danych, + # a cała warstwa OAuth staje się dekoracją. + set_current_bearer(dane.bearer) try: if sciezka == SCIEZKA_Z_LOGOWANIEM: # SDK montuje trasę jako Route (^/mcp$), nie Mount — bez @@ -1227,6 +1276,10 @@ class RouterHttp: await self._publiczny(scope, receive, send) finally: dane_zadania.reset(zeton) + # set_current_bearer nie zwraca tokenu resetu, więc czyścimy + # jawnie — inaczej token wyciekłby do następnego żądania + # obsłużonego w tym samym kontekście. + set_current_bearer(None) @staticmethod def _chce_html(scope) -> bool: @@ -1235,10 +1288,6 @@ class RouterHttp: return True return False - @staticmethod - def _z_kontekstem(scope): - return scope - @staticmethod def _dane(scope) -> DaneZadania: naglowki = {k.lower(): v.decode() for k, v in scope.get("headers", [])} From 3dbb4ec700df3b6b3adaccaabb5c637881bbe27a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sun, 6 Sep 2026 16:02:37 +0200 Subject: [PATCH 15/29] =?UTF-8?q?feat(mcp=5Fserver):=20BramkaBearera=20?= =?UTF-8?q?=E2=80=94=20401=20z=20PRM=20przy=20zachowaniu=20anonima?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU --- src/mcp_server/auth.py | 98 +++++++++++++++++++++++++++++++ src/mcp_server/tests/test_auth.py | 91 ++++++++++++++++++++++++++++ 2 files changed, 189 insertions(+) create mode 100644 src/mcp_server/auth.py create mode 100644 src/mcp_server/tests/test_auth.py diff --git a/src/mcp_server/auth.py b/src/mcp_server/auth.py new file mode 100644 index 000000000..cb67d9d52 --- /dev/null +++ b/src/mcp_server/auth.py @@ -0,0 +1,98 @@ +"""Weryfikacja bearera przed aplikacją MCP. + +SDK MCP montuje ``RequireAuthMiddleware`` wyłącznie przy podanym +``token_verifier``, a ten odrzuca 401-ką KAŻDE żądanie bez tożsamości — czyli +wyklucza dostęp anonimowy, na którym nam zależy. Stąd własna warstwa +(spec §5.4). +""" + +from __future__ import annotations + +import json + +from asgiref.sync import sync_to_async +from django.db import close_old_connections + +WYMAGANY_SCOPE = "read" + + +def _sprawdz(raw: str) -> bool: + """Czy token jest ważny wg TYCH SAMYCH reguł, co DRF. + + ``StrictOAuth2Authentication`` wymaga ważnego tokenu, aktywnego konta ORAZ + scope'u ``read``. Sprawdzanie tu mniej oznaczałoby, że token bez scope'u + przechodzi bramkę i pada dopiero w DRF — czyli wraca jako błąd w treści + JSON-RPC z HTTP 200, a klient nigdy nie ponawia autoryzacji. + """ + from oauth2_provider.models import get_access_token_model + + close_old_connections() # poza cyklem żądania Django — brak sygnałów + try: + token = ( + get_access_token_model() + .objects.select_related("user") + .filter(token=raw) + .first() + ) + if token is None or not token.is_valid(): + return False + if token.user is None or not token.user.is_active: + return False + return bool(token.allow_scopes([WYMAGANY_SCOPE])) + finally: + close_old_connections() + + +zweryfikuj_token = sync_to_async(_sprawdz, thread_sensitive=False) + + +class BramkaBearera: + """Przepuszcza anonima (gdy wolno) i odrzuca nieważny token 401-ką.""" + + def __init__(self, aplikacja, *, wymagany: bool) -> None: + self._aplikacja = aplikacja + self._wymagany = wymagany + + async def __call__(self, scope, receive, send): + naglowki = {k.lower(): v for k, v in scope.get("headers", [])} + surowy = naglowki.get(b"authorization", b"").decode(errors="replace") + schemat, _, wartosc = surowy.partition(" ") + + if not surowy: + if self._wymagany: + await self._odmow(scope, send) + return + await self._aplikacja(scope, receive, send) + return + + if schemat.lower() != "bearer" or not await zweryfikuj_token(wartosc.strip()): + await self._odmow(scope, send) + return + + await self._aplikacja(scope, receive, send) + + async def _odmow(self, scope, send) -> None: + naglowki = {k.lower(): v for k, v in scope.get("headers", [])} + host = naglowki.get(b"host", b"localhost").decode() + schemat = "https" if scope.get("scheme") in ("https", "wss") else "http" + prm = f"{schemat}://{host}/.well-known/oauth-protected-resource" + cialo = json.dumps( + { + "error": "invalid_token", + "error_description": "Wymagany ważny token OAuth.", + } + ).encode() + await send( + { + "type": "http.response.start", + "status": 401, + "headers": [ + (b"content-type", b"application/json"), + ( + b"www-authenticate", + f'Bearer resource_metadata="{prm}"'.encode(), + ), + ], + } + ) + await send({"type": "http.response.body", "body": cialo}) diff --git a/src/mcp_server/tests/test_auth.py b/src/mcp_server/tests/test_auth.py new file mode 100644 index 000000000..1d291412d --- /dev/null +++ b/src/mcp_server/tests/test_auth.py @@ -0,0 +1,91 @@ +"""BramkaBearera: 401 z WWW-Authenticate, przy zachowaniu dostępu anonimowego. + +Bramka MUSI sprawdzać dokładnie to, co StrictOAuth2Authentication: ważność +tokenu, aktywność konta ORAZ scope ``read``. Gdyby sprawdzała mniej, token bez +scope'u przeszedłby tutaj i padł dopiero w DRF — czyli wróciłby jako błąd +w treści JSON-RPC z HTTP 200 i klient nigdy nie ponowiłby autoryzacji +(spec §5.4). +""" + +import pytest +from django.contrib.auth import get_user_model +from model_bakery import baker +from oauth2_provider.models import get_access_token_model, get_application_model + +from mcp_server.auth import BramkaBearera +from mcp_server.tests.utils import uruchom, wywolaj, zbuduj_scope + + +async def _ok(scope, receive, send): + await send({"type": "http.response.start", "status": 200, "headers": []}) + await send({"type": "http.response.body", "body": b"PRZESZLO"}) + + +def _token(scope="read", aktywny=True, wygasly=False): + from datetime import timedelta + + from django.utils import timezone + + user = baker.make(get_user_model(), is_active=aktywny) + app = get_application_model().objects.create( + name="test", + client_type="public", + authorization_grant_type="authorization-code", + ) + return get_access_token_model().objects.create( + user=user, + application=app, + token="TOKEN123", + scope=scope, + expires=timezone.now() + timedelta(seconds=-1 if wygasly else 3600), + ) + + +@pytest.mark.django_db(transaction=True) +def test_publiczny_bez_naglowka_przepuszcza(): + app = BramkaBearera(_ok, wymagany=False) + status, _, tresc = uruchom(lambda: wywolaj(app, zbuduj_scope("/mcp"))) + assert status == 200 and tresc == "PRZESZLO" + + +@pytest.mark.django_db(transaction=True) +def test_wymagany_bez_naglowka_daje_401_z_naglowkiem(): + app = BramkaBearera(_ok, wymagany=True) + status, naglowki, _ = uruchom(lambda: wywolaj(app, zbuduj_scope("/mcp/auth"))) + assert status == 401 + assert "resource_metadata=" in naglowki["www-authenticate"] + + +@pytest.mark.django_db(transaction=True) +def test_zly_token_daje_401_takze_na_publicznym(): + app = BramkaBearera(_ok, wymagany=False) + scope = zbuduj_scope("/mcp", naglowki={"authorization": "Bearer NIEISTNIEJE"}) + status, _, _ = uruchom(lambda: wywolaj(app, scope)) + assert status == 401 + + +@pytest.mark.django_db(transaction=True) +def test_token_bez_scope_read_odrzucony_juz_w_bramce(): + _token(scope="write") + app = BramkaBearera(_ok, wymagany=False) + scope = zbuduj_scope("/mcp", naglowki={"authorization": "Bearer TOKEN123"}) + status, _, _ = uruchom(lambda: wywolaj(app, scope)) + assert status == 401 + + +@pytest.mark.django_db(transaction=True) +def test_konto_nieaktywne_odrzucone(): + _token(aktywny=False) + app = BramkaBearera(_ok, wymagany=False) + scope = zbuduj_scope("/mcp", naglowki={"authorization": "Bearer TOKEN123"}) + status, _, _ = uruchom(lambda: wywolaj(app, scope)) + assert status == 401 + + +@pytest.mark.django_db(transaction=True) +def test_wazny_token_przechodzi(): + _token() + app = BramkaBearera(_ok, wymagany=False) + scope = zbuduj_scope("/mcp", naglowki={"authorization": "Bearer TOKEN123"}) + status, _, tresc = uruchom(lambda: wywolaj(app, scope)) + assert status == 200 and tresc == "PRZESZLO" From 6e5e07f42d4d46cebb3d82b1c60aeac095385d67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sun, 6 Sep 2026 16:29:42 +0200 Subject: [PATCH 16/29] feat(mcp_server): RouterHttp z przepisaniem /mcp/auth + LifespanMcp 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) Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU --- src/mcp_server/routing.py | 165 ++++++++++++++++++++++++ src/mcp_server/tests/test_routing.py | 186 +++++++++++++++++++++++++++ 2 files changed, 351 insertions(+) create mode 100644 src/mcp_server/routing.py create mode 100644 src/mcp_server/tests/test_routing.py diff --git a/src/mcp_server/routing.py b/src/mcp_server/routing.py new file mode 100644 index 000000000..22d46ba82 --- /dev/null +++ b/src/mcp_server/routing.py @@ -0,0 +1,165 @@ +"""Routing ASGI: rozdział /mcp od reszty serwisu oraz obsługa lifespanu.""" + +from __future__ import annotations + +import time + +from bpp_mcp.auth import set_current_bearer +from django.conf import settings + +from django_bpp.client_ip import get_client_ip +from mcp_server.auth import BramkaBearera +from mcp_server.kontekst import DaneZadania, dane_zadania +from mcp_server.start import StartMcp + +#: Budżet czasu jednego wywołania narzędzia (spec D11). +BUDZET_SEKUND = getattr(settings, "MCP_BUDZET_SEKUND", 25.0) + +SCIEZKA_PUBLICZNA = "/mcp" +SCIEZKA_Z_LOGOWANIEM = "/mcp/auth" + + +class LifespanMcp: + """Obsługuje scope ``lifespan``, którego ProtocolTypeRouter nie zna. + + Klucza ``"lifespan"`` po prostu nie ma dziś w mapie routera, więc uvicorn + dostaje ValueError, loguje „lifespan appears unsupported" i jedzie dalej — + a menedżer sesji MCP nigdy nie wstaje (spec §2.4). + """ + + def __init__(self, start: StartMcp) -> None: + self._start = start + + async def __call__(self, scope, receive, send): + while True: + komunikat = await receive() + if komunikat["type"] == "lifespan.startup": + try: + await self._start.zapewnij() + except Exception as exc: + # Świadomie NIE połykamy: pod gunicornem worker padnie + # i zostanie respawnowany, czyli awaria będzie widoczna + # jako boot-loop, a nie jako cisza (spec §5.1). + await send({"type": "lifespan.startup.failed", "message": str(exc)}) + return + await send({"type": "lifespan.startup.complete"}) + elif komunikat["type"] == "lifespan.shutdown": + await send({"type": "lifespan.shutdown.complete"}) + return + + +class RouterHttp: + """Kieruje ``/mcp`` i ``/mcp/auth`` do MCP, resztę do Django.""" + + def __init__(self, mcp_app, django_app, start: StartMcp) -> None: + self._django = django_app + self._start = start + self._publiczny = BramkaBearera(mcp_app, wymagany=False) + self._z_logowaniem = BramkaBearera(mcp_app, wymagany=True) + + async def __call__(self, scope, receive, send): + sciezka = scope.get("path", "") + if sciezka not in (SCIEZKA_PUBLICZNA, SCIEZKA_Z_LOGOWANIEM): + await self._django(scope, receive, send) + return + + if scope.get("method") == "GET" and self._chce_html(scope): + await self._przekieruj(send, "/mcp/") + return + + # PIERWSZA instrukcja i POZA jakimkolwiek cancel scope'em (spec §5.1). + await self._start.zapewnij() + if not self._start.zywy: + await self._niedostepny(send) + return + + dane = self._dane(scope) + zeton = dane_zadania.set(dane) + # DWA ContextVary, nie jeden. `BppClient._auth_kwargs` czyta token + # z WŁASNEGO ContextVara pakietu bpp_mcp (`bpp_mcp.auth`), nie + # z naszego. Bez tej linii `DaneZadania.bearer` nie ma żadnej drogi + # do żądania wychodzącego i KAŻDE wywołanie leci anonimowo — czyli + # zalogowany użytkownik po cichu traci dostęp do swoich danych, + # a cała warstwa OAuth staje się dekoracją. + set_current_bearer(dane.bearer) + try: + if sciezka == SCIEZKA_Z_LOGOWANIEM: + # SDK montuje trasę jako Route (^/mcp$), nie Mount — bez + # przepisania drugi adres dostałby 404 (spec §5.1, BL-1). + scope = dict(scope, path=SCIEZKA_PUBLICZNA, raw_path=b"/mcp") + await self._z_logowaniem(scope, receive, send) + else: + await self._publiczny(scope, receive, send) + finally: + dane_zadania.reset(zeton) + # set_current_bearer nie zwraca tokenu resetu, więc czyścimy + # jawnie — inaczej token wyciekłby do następnego żądania + # obsłużonego w tym samym kontekście. + set_current_bearer(None) + + @staticmethod + def _chce_html(scope) -> bool: + for klucz, wartosc in scope.get("headers", []): + if klucz.lower() == b"accept" and b"text/html" in wartosc.lower(): + return True + return False + + @staticmethod + def _dane(scope) -> DaneZadania: + # UWAGA: klucze ASGI to bajty — bez ``.decode()`` na kluczu każde + # `naglowki.get("host", …)` z napisowym kluczem chybia (bajty != str) + # i ZAWSZE trafia w wartość domyślną. Skutek po cichu: bearer + # zawsze None, host zawsze "localhost" — mostek z pkt 2 briefu + # wygląda podłączony, a nic nie przenosi. + naglowki = {k.lower().decode(): v.decode() for k, v in scope.get("headers", [])} + host = naglowki.get("host", "localhost") + # nginx→uvicorn jest plaintext; prawdziwy schemat niesie + # X-Forwarded-Proto (spec §7.6). Nagłówka NIE przekazujemy dalej — + # służy wyłącznie do zbudowania adresu bazowego. + scheme = naglowki.get("x-forwarded-proto", scope.get("scheme", "http")) + bearer = None + surowy = naglowki.get("authorization", "") + if surowy.lower().startswith("bearer "): + bearer = surowy.split(" ", 1)[1].strip() + return DaneZadania( + host=host, + scheme="https" if scheme == "https" else "http", + ip=get_client_ip(_UdajeRequest(scope, naglowki)), + bearer=bearer, + deadline=time.monotonic() + BUDZET_SEKUND, + ) + + @staticmethod + async def _przekieruj(send, gdzie: str) -> None: + await send( + { + "type": "http.response.start", + "status": 307, + "headers": [(b"location", gdzie.encode())], + } + ) + await send({"type": "http.response.body", "body": b""}) + + @staticmethod + async def _niedostepny(send) -> None: + await send( + { + "type": "http.response.start", + "status": 503, + "headers": [(b"content-type", b"application/json")], + } + ) + await send( + {"type": "http.response.body", "body": b'{"error":"mcp_unavailable"}'} + ) + + +class _UdajeRequest: + """Minimalny obiekt zgodny z tym, czego oczekuje ``get_client_ip``.""" + + def __init__(self, scope, naglowki): + klient = scope.get("client") or ("", 0) + self.META = {"REMOTE_ADDR": klient[0]} + xff = naglowki.get("x-forwarded-for") + if xff: + self.META["HTTP_X_FORWARDED_FOR"] = xff diff --git a/src/mcp_server/tests/test_routing.py b/src/mcp_server/tests/test_routing.py new file mode 100644 index 000000000..0536b84cc --- /dev/null +++ b/src/mcp_server/tests/test_routing.py @@ -0,0 +1,186 @@ +"""RouterHttp: dopasowanie DOKŁADNE + przepisanie ścieżki dla /mcp/auth. + +SDK montuje trasę jako Starlette ``Route`` (regex ^/mcp$), nie ``Mount``, więc +bez przepisania ścieżki /mcp/auth dostałoby 404 (spec §5.1, bloker BL-1). +Dopasowanie prefiksowe byłoby dodatkowo sprzeczne z §8: /mcp/ ma iść do Django. +""" + +import pytest + +from mcp_server.routing import LifespanMcp, RouterHttp +from mcp_server.start import StartMcp +from mcp_server.tests.utils import uruchom, wywolaj, zbuduj_scope + + +def _token(raw: str, *, scope: str = "read", aktywny: bool = True): + """Realny access token w bazie — jak w ``test_auth.py`` (Task 5). + + ``BramkaBearera`` sprawdza WAŻNOŚĆ tokenu w bazie niezależnie od + ``wymagany`` (spec §5.4, tabela D10: „zły / wygasły / bez scope" → 401 + na OBU adresach). Testy poniżej, które przepuszczają bearer przez + prawdziwą bramkę, muszą więc dać jej token, który faktycznie istnieje — + zmyślony ciąg zostałby odrzucony 401-ką, zanim aplikacja MCP go zobaczy. + """ + from datetime import timedelta + + from django.contrib.auth import get_user_model + from django.utils import timezone + from model_bakery import baker + from oauth2_provider.models import get_access_token_model, get_application_model + + user = baker.make(get_user_model(), is_active=aktywny) + app = get_application_model().objects.create( + name=f"test-{raw}", + client_type="public", + authorization_grant_type="authorization-code", + ) + return get_access_token_model().objects.create( + user=user, + application=app, + token=raw, + scope=scope, + expires=timezone.now() + timedelta(seconds=3600), + ) + + +class _AtrapaLifespanu: + def __init__(self): + self.router = self + + def lifespan_context(self, _app): + from contextlib import asynccontextmanager + + @asynccontextmanager + async def _ctx(): + yield + + return _ctx() + + +async def _echo_sciezki(scope, receive, send): + await send({"type": "http.response.start", "status": 200, "headers": []}) + await send({"type": "http.response.body", "body": b"MCP:" + scope["path"].encode()}) + + +async def _django(scope, receive, send): + await send({"type": "http.response.start", "status": 200, "headers": []}) + await send( + {"type": "http.response.body", "body": b"DJANGO:" + scope["path"].encode()} + ) + + +def _router(): + return RouterHttp(_echo_sciezki, _django, StartMcp(_AtrapaLifespanu())) + + +def test_mcp_trafia_do_aplikacji_mcp(): + status, _, tresc = uruchom(lambda: wywolaj(_router(), zbuduj_scope("/mcp"))) + assert status == 200 and tresc == "MCP:/mcp" + + +@pytest.mark.django_db(transaction=True) +def test_mcp_auth_ma_przepisana_sciezke_na_mcp(): + """REGRESJA BL-1: bez przepisania SDK oddałoby 404. + + ``/mcp/auth`` bez ważnego tokenu to zawsze 401 (spec §5.4, D10) — bez + tokenu bramka odrzuciłaby żądanie PRZED aplikacją MCP niezależnie od + przepisania ścieżki, więc dowodem na BL-1 może być tylko przejście + z WAŻNYM tokenem. + """ + _token("TOKEN-BL1") + scope = zbuduj_scope("/mcp/auth", naglowki={"authorization": "Bearer TOKEN-BL1"}) + status, _, tresc = uruchom(lambda: wywolaj(_router(), scope)) + assert status == 200 and tresc == "MCP:/mcp" + + +def test_mcp_ze_slashem_idzie_do_django(): + status, _, tresc = uruchom( + lambda: wywolaj(_router(), zbuduj_scope("/mcp/", metoda="GET")) + ) + assert tresc == "DJANGO:/mcp/" + + +def test_reszta_idzie_do_django(): + status, _, tresc = uruchom( + lambda: wywolaj(_router(), zbuduj_scope("/api/v1/", metoda="GET")) + ) + assert tresc == "DJANGO:/api/v1/" + + +def test_get_mcp_z_przegladarki_przekierowuje_na_slash(): + """Człowiek wkleja adres z instrukcji; bez tego SDK oddałby 406.""" + scope = zbuduj_scope("/mcp", metoda="GET", naglowki={"accept": "text/html"}) + status, naglowki, _ = uruchom(lambda: wywolaj(_router(), scope)) + assert status == 307 and naglowki["location"] == "/mcp/" + + +@pytest.mark.django_db(transaction=True) +def test_bearer_trafia_do_contextvara_pakietu(): + """`BppClient` czyta token z WŁASNEGO ContextVara pakietu bpp_mcp, nie + z naszego. Bez tego mostka zalogowany użytkownik po cichu leciałby + anonimowo, a warstwa OAuth byłaby dekoracją. + + Token musi być WAŻNY (patrz ``_token``) — bramka na ``/mcp`` przepuszcza + anonima, ale zły/nieznany bearer odrzuca 401-ką (spec §5.4) i mock + aplikacji MCP (``_podglada``) nigdy by nie został wywołany. + """ + from bpp_mcp.auth import current_bearer + + _token("TOKEN-XYZ") + widziany = [] + + async def _podglada(scope, receive, send): + widziany.append(current_bearer()) + await send({"type": "http.response.start", "status": 200, "headers": []}) + await send({"type": "http.response.body", "body": b""}) + + router = RouterHttp(_podglada, _django, StartMcp(_AtrapaLifespanu())) + scope = zbuduj_scope("/mcp", naglowki={"authorization": "Bearer TOKEN-XYZ"}) + uruchom(lambda: wywolaj(router, scope)) + assert widziany == ["TOKEN-XYZ"] + + +@pytest.mark.django_db(transaction=True) +def test_bearer_nie_wycieka_do_nastepnego_zadania(): + """Po zakończeniu żądania ContextVar pakietu musi być wyczyszczony.""" + from bpp_mcp.auth import current_bearer + + _token("T1") + widziany = [] + + async def _podglada(scope, receive, send): + widziany.append(current_bearer()) + await send({"type": "http.response.start", "status": 200, "headers": []}) + await send({"type": "http.response.body", "body": b""}) + + async def scenariusz(): + router = RouterHttp(_podglada, _django, StartMcp(_AtrapaLifespanu())) + z_tokenem = zbuduj_scope("/mcp", naglowki={"authorization": "Bearer T1"}) + await wywolaj(router, z_tokenem) + await wywolaj(router, zbuduj_scope("/mcp")) + + uruchom(scenariusz) + assert widziany == ["T1", None] + + +def test_lifespan_startup_complete(): + start = StartMcp(_AtrapaLifespanu()) + app = LifespanMcp(start) + + async def scenariusz(): + zdarzenia = [{"type": "lifespan.startup"}, {"type": "lifespan.shutdown"}] + odpowiedzi = [] + + async def receive(): + return zdarzenia.pop(0) + + async def send(m): + odpowiedzi.append(m) + + await app({"type": "lifespan"}, receive, send) + return [m["type"] for m in odpowiedzi] + + assert uruchom(scenariusz) == [ + "lifespan.startup.complete", + "lifespan.shutdown.complete", + ] From f4d00d29b1110c5f6b7b7efd07ebb2b267e73038 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sun, 6 Sep 2026 16:39:36 +0200 Subject: [PATCH 17/29] =?UTF-8?q?docs(plan):=20popraw=20klucze=20nag=C5=82?= =?UTF-8?q?=C3=B3wk=C3=B3w=20ASGI=20w=20Task=206?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU --- .../plans/2026-09-06-mcp-hostowany-w-serwisie.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-09-06-mcp-hostowany-w-serwisie.md b/docs/superpowers/plans/2026-09-06-mcp-hostowany-w-serwisie.md index 7efbb9440..3d49b25b6 100644 --- a/docs/superpowers/plans/2026-09-06-mcp-hostowany-w-serwisie.md +++ b/docs/superpowers/plans/2026-09-06-mcp-hostowany-w-serwisie.md @@ -1290,7 +1290,11 @@ class RouterHttp: @staticmethod def _dane(scope) -> DaneZadania: - naglowki = {k.lower(): v.decode() for k, v in scope.get("headers", [])} + # Klucze nagłówków w ASGI to BYTES — bez .decode() na kluczu + # każdy odczyt po stringu chybia i bearer cicho ginie. + naglowki = { + k.lower().decode(): v.decode() for k, v in scope.get("headers", []) + } host = naglowki.get("host", "localhost") # nginx→uvicorn jest plaintext; prawdziwy schemat niesie # X-Forwarded-Proto (spec §7.6). Nagłówka NIE przekazujemy dalej — From 66c4df0be1bca72b23a3f44ae7328a5405166f76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sun, 6 Sep 2026 16:48:29 +0200 Subject: [PATCH 18/29] =?UTF-8?q?feat(mcp=5Fserver):=20build=5Fapplication?= =?UTF-8?q?=20z=20narz=C4=99dziami=20i=20polityk=C4=85=20host=C3=B3w?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU --- src/mcp_server/aplikacja.py | 83 ++++++++++++++++++++++++++ src/mcp_server/tests/test_aplikacja.py | 24 ++++++++ 2 files changed, 107 insertions(+) create mode 100644 src/mcp_server/aplikacja.py diff --git a/src/mcp_server/aplikacja.py b/src/mcp_server/aplikacja.py new file mode 100644 index 000000000..631bab95e --- /dev/null +++ b/src/mcp_server/aplikacja.py @@ -0,0 +1,83 @@ +"""Budowa aplikacji MCP: serwer SDK + narzędzia z bpp-mcp + polityka hostów.""" + +from __future__ import annotations + +from contextlib import asynccontextmanager + +from bpp_mcp import register_tools +from django.conf import settings +from mcp.server.mcpserver import MCPServer +from mcp.server.transport_security import TransportSecuritySettings + +from mcp_server.klient import zbuduj_klienta +from mcp_server.start import StartMcp + + +class KontekstZadania: + """Kontrakt lifespanu ``register_tools``, ale klient jest PER ŻĄDANIE. + + ``register_tools`` dokumentuje, że lifespan ma oddać ``KontekstApp`` *albo + obiekt o tych samych atrybutach*. Korzystamy z tej furtki: ``client`` jest + property, więc wrappery narzędzi — które czytają go przy każdym wywołaniu — + dostają klienta zbudowanego dla bieżącego żądania (spec D9). + """ + + #: Host wielo-użytkownikowy: token jednej osoby nie może trafić do żądania + #: innej, więc NIGDY nie sięgamy do lokalnego cache tokenów. + bearer_provider = None + + @property + def client(self): + return zbuduj_klienta() + + +def _dozwolone_hosty() -> list[str]: + """Przetłumacz ``ALLOWED_HOSTS`` na semantykę SDK. + + SDK dopasowuje host dokładnie albo wzorcem ``host:*`` — Django-owe + ``.domena`` i ``*`` nie działają (spec §6.1). ``*`` w ALLOWED_HOSTS + (używane w testach) mapujemy na wyłączenie sprawdzania, bo nie ma dla + niego odpowiednika. + """ + hosty: list[str] = [] + for wpis in settings.ALLOWED_HOSTS: + if wpis == "*": + return [] + hosty.append(wpis.lstrip(".")) + hosty.append(f"{wpis.lstrip('.')}:*") + return hosty + + +def build_application(): + """Zbuduj aplikację ASGI serwera MCP i jej ``StartMcp``. + + FABRYKA, nie moduł: allowlista hostów jest liczona z ``settings``, więc + zbudowanie aplikacji przy imporcie uniemożliwiłoby testom nadpisanie + ``ALLOWED_HOSTS`` (spec §11). + """ + + @asynccontextmanager + async def lifespan(_serwer): + yield KontekstZadania() + + serwer = MCPServer("bpp", version="1", lifespan=lifespan) + register_tools(serwer) + + hosty = _dozwolone_hosty() + bezpieczenstwo = TransportSecuritySettings( + enable_dns_rebinding_protection=bool(hosty), + allowed_hosts=hosty, + allowed_origins=[f"https://{h}" for h in hosty if not h.endswith(":*")], + ) + + aplikacja = serwer.streamable_http_app( + streamable_http_path="/mcp", + # Bezstanowo: recykling workera (--max-requests) zabiłby sesję + # stateful w środku pracy użytkownika. json_response znosi SSE, więc + # proxy_read_timeout nginksa przestaje być tematem (spec D3). + stateless_http=True, + json_response=True, + transport_security=bezpieczenstwo, + ) + aplikacja.state.serwer_mcp = serwer + return aplikacja, StartMcp(aplikacja) diff --git a/src/mcp_server/tests/test_aplikacja.py b/src/mcp_server/tests/test_aplikacja.py index d1a8845da..9ec69827e 100644 --- a/src/mcp_server/tests/test_aplikacja.py +++ b/src/mcp_server/tests/test_aplikacja.py @@ -2,6 +2,9 @@ from django.apps import apps +from mcp_server.aplikacja import build_application +from mcp_server.tests.utils import uruchom + def test_aplikacja_zarejestrowana(): assert apps.is_installed("mcp_server") @@ -11,3 +14,24 @@ def test_pakiet_bpp_mcp_dostepny(): from bpp_mcp import register_tools assert callable(register_tools) + + +def test_zarejestrowano_komplet_narzedzi(): + """11 narzędzi + 1 prompt — tyle daje register_tools z bpp-mcp 0.4.0.""" + mcp_app, _ = build_application() + + async def scenariusz(): + serwer = mcp_app.state.serwer_mcp + return len(await serwer.list_tools()), len(await serwer.list_prompts()) + + narzedzia, prompty = uruchom(scenariusz) + assert narzedzia == 11 + assert prompty == 1 + + +def test_fabryka_daje_nowa_instancje_za_kazdym_razem(): + """Aplikacja MUSI powstawać fabryką: allowlista hostów liczona przy + imporcie nie zobaczyłaby settings nadpisanych w teście (spec §11).""" + a, _ = build_application() + b, _ = build_application() + assert a is not b From 3c9bb99843ea9ec5727548f8e8c3bfad57b6c861 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sun, 6 Sep 2026 17:04:25 +0200 Subject: [PATCH 19/29] feat(asgi): zamontuj /mcp obok Django, dopisz klucz lifespan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU --- src/django_bpp/asgi.py | 14 +++++++- src/mcp_server/tests/test_e2e.py | 58 ++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 src/mcp_server/tests/test_e2e.py diff --git a/src/django_bpp/asgi.py b/src/django_bpp/asgi.py index 01787f16b..2d368efa2 100644 --- a/src/django_bpp/asgi.py +++ b/src/django_bpp/asgi.py @@ -22,11 +22,23 @@ # channels_broadcast działają dalej bez zmian. websocket_urlpatterns = liveops.routing.websocket_urlpatterns +# Aplikacja MCP i jej routing. Import PO get_asgi_application() — mcp_server +# sięga po modele przez klienta w procesie. +from mcp_server.aplikacja import build_application # noqa: E402 +from mcp_server.routing import LifespanMcp, RouterHttp # noqa: E402 + +_mcp_app, _mcp_start = build_application() + application = ProtocolTypeRouter( { - "http": django_asgi_app, + "http": RouterHttp(_mcp_app, django_asgi_app, _mcp_start), "websocket": AllowedHostsOriginValidator( AuthMiddlewareStack(URLRouter(websocket_urlpatterns)) ), + # 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 (spec §2.4, §5.1). + "lifespan": LifespanMcp(_mcp_start), } ) diff --git a/src/mcp_server/tests/test_e2e.py b/src/mcp_server/tests/test_e2e.py new file mode 100644 index 000000000..135c4d355 --- /dev/null +++ b/src/mcp_server/tests/test_e2e.py @@ -0,0 +1,58 @@ +"""Pełna ścieżka: POST /mcp → initialize → tools/list, bez sieci.""" + +import pytest + +from mcp_server.aplikacja import build_application +from mcp_server.routing import RouterHttp +from mcp_server.tests.utils import uruchom, wywolaj, zbuduj_scope + + +async def _django(scope, receive, send): + await send({"type": "http.response.start", "status": 404, "headers": []}) + await send({"type": "http.response.body", "body": b""}) + + +def _router(settings): + settings.ALLOWED_HOSTS = ["bpp.example.test"] + mcp_app, start = build_application() + return RouterHttp(mcp_app, _django, start) + + +@pytest.mark.django_db(transaction=True) +def test_initialize_zwraca_serverinfo(settings): + router = _router(settings) + scope = zbuduj_scope( + "/mcp", + naglowki={ + "content-type": "application/json", + "accept": "application/json, text/event-stream", + }, + ) + cialo = { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": {"name": "test", "version": "0"}, + }, + } + status, _, tresc = uruchom(lambda: wywolaj(router, scope, cialo)) + assert status == 200 + assert '"serverInfo"' in tresc and '"bpp"' in tresc + + +@pytest.mark.django_db(transaction=True) +def test_zle_host_daje_421(settings): + """Ochrona przed DNS-rebinding SDK działa niezależnie od ALLOWED_HOSTS.""" + router = _router(settings) + scope = zbuduj_scope( + "/mcp", + host="zly.host", + naglowki={"content-type": "application/json"}, + ) + status, _, _ = uruchom( + lambda: wywolaj(router, scope, {"jsonrpc": "2.0", "id": 1, "method": "ping"}) + ) + assert status in (403, 421) From e781a4879578490529af272844aa8529510f3063 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sun, 6 Sep 2026 17:26:33 +0200 Subject: [PATCH 20/29] =?UTF-8?q?test(mcp=5Fserver):=20subprocesowy=20test?= =?UTF-8?q?=20monta=C5=BCu=20/mcp=20w=20asgi.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 , nie RouterHttp — MCP nie jest zamontowane obok Django. Zmiana przywrócona, potwierdzone git diff czyste. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU --- src/mcp_server/tests/test_asgi_montaz.py | 98 ++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 src/mcp_server/tests/test_asgi_montaz.py diff --git a/src/mcp_server/tests/test_asgi_montaz.py b/src/mcp_server/tests/test_asgi_montaz.py new file mode 100644 index 000000000..00fec642b --- /dev/null +++ b/src/mcp_server/tests/test_asgi_montaz.py @@ -0,0 +1,98 @@ +"""Test chroniący faktyczny montaż MCP w ``django_bpp.asgi.application``. + +Żaden inny test nie dotyka ``django_bpp.asgi`` — ``test_e2e.py`` i reszta +testów ``mcp_server`` budują ``RouterHttp``/``LifespanMcp`` własnoręcznie +przez ``build_application()``, więc przechodzą identycznie, nawet gdy ktoś +(np. przy konflikcie merge'a) przywróci w ``asgi.py`` stary +``"http": django_asgi_app`` i usunie klucz ``"lifespan"``. Cały pakiet +świeciłby wtedy zielono, a produkcja nigdy nie odpowiadałaby na ``/mcp`` +(gunicorn loguje tylko „lifespan appears unsupported" i jedzie dalej). + +Musi być SUBPROCESEM: ``build_application()`` w ``asgi.py`` wykonuje się +raz, przy pierwszym imporcie modułu, i ta jedna instancja żyje tyle, co +proces — ``session_manager.run()`` wchodzi się raz na instancję (patrz +``mcp_server/start.py``). W procesie pytest wszystkie testy dzielą już +zaimportowany ``django_bpp.asgi`` z poprzednich testów (import jest +cache'owany w ``sys.modules``), więc nie da się tu uzyskać świeżej, +nieużywanej instancji — subproces daje czysty import i pełną izolację. +""" + +import os +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[3] + +# Skrypt sprawdza DWIE rzeczy niezależnie: +# 1. strukturę application_mapping (to jest to, co pada, gdy ktoś przywróci +# stary blok "http": django_asgi_app i usunie klucz "lifespan"), +# 2. że lifespan realnie się otwiera i zamyka PRZEZ ProtocolTypeRouter +# (nie przez LifespanMcp zbudowany osobno w teście) — startup.complete +# musi się pojawić w wysłanych komunikatach, inaczej menedżer sesji MCP +# nigdy nie wstaje (spec §2.4, §5.1). +SKRYPT = """ +import asyncio + +import django_bpp.asgi as m +from mcp_server.routing import LifespanMcp, RouterHttp + +mapowanie = m.application.application_mapping +assert isinstance(mapowanie["http"], RouterHttp), ( + f"'http' to {type(mapowanie['http'])!r}, nie RouterHttp — MCP nie jest " + "zamontowane obok Django." +) +assert isinstance(mapowanie["lifespan"], LifespanMcp), ( + f"'lifespan' to {type(mapowanie.get('lifespan'))!r}, nie LifespanMcp — " + "menedżer sesji MCP nigdy nie wstanie pod uvicornem/gunicornem." +) + + +async def przejdz_cykl_lifespan(): + wyslane = [] + + async def send(msg): + wyslane.append(msg) + + kolejka = [{"type": "lifespan.startup"}, {"type": "lifespan.shutdown"}] + + async def receive(): + return kolejka.pop(0) + + # Przez application (ProtocolTypeRouter), nie przez LifespanMcp z boku — + # to sprawdza też, że router faktycznie dysponuje scope["type"]=="lifespan". + await m.application({"type": "lifespan"}, receive, send) + return wyslane + + +wyslane = asyncio.run(przejdz_cykl_lifespan()) +typy = [m["type"] for m in wyslane] +assert typy == ["lifespan.startup.complete", "lifespan.shutdown.complete"], typy +print("OK") +""" + + +def test_application_mapping_montuje_mcp_i_lifespan_wstaje(): + """``django_bpp.asgi.application`` musi realnie obsługiwać /mcp i lifespan. + + Odpalone jako osobny proces (patrz docstring modułu) z tym samym + ``os.environ`` co proces pytest — w tym ``DJANGO_SETTINGS_MODULE``, + które ustawia pytest-django (``--ds=django_bpp.settings.test``), oraz + zmienne testcontainers wstrzyknięte przez fixture. Test nie dotyka ORM + (nie ma ``@pytest.mark.django_db``) — sprawdza strukturę routera + i cykl lifespanu, obu bez zapytań do bazy. + """ + wynik = subprocess.run( + [sys.executable, "-c", SKRYPT], + cwd=REPO_ROOT, + env=os.environ.copy(), + capture_output=True, + text=True, + timeout=60, + ) + + assert wynik.returncode == 0, ( + "MCP nie jest poprawnie zamontowane w django_bpp.asgi.application.\n" + f"STDOUT:\n{wynik.stdout}\n\nSTDERR:\n{wynik.stderr}" + ) + assert "OK" in wynik.stdout From 7b7ff1b4a84e164bae18fa6606fa68c8a0b02396 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sun, 6 Sep 2026 17:46:06 +0200 Subject: [PATCH 21/29] feat(oauth_mcp): metadane protected-resource (RFC 9728) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU --- src/oauth_mcp/tests/test_metadata_prm.py | 32 ++++++++++++++++++++++++ src/oauth_mcp/urls.py | 10 +++++++- src/oauth_mcp/views_metadata.py | 18 +++++++++++++ 3 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 src/oauth_mcp/tests/test_metadata_prm.py diff --git a/src/oauth_mcp/tests/test_metadata_prm.py b/src/oauth_mcp/tests/test_metadata_prm.py new file mode 100644 index 000000000..1ed7f650d --- /dev/null +++ b/src/oauth_mcp/tests/test_metadata_prm.py @@ -0,0 +1,32 @@ +"""PRM (RFC 9728) — wskazuje klientowi, gdzie jest serwer autoryzacji.""" + +import pytest + + +@pytest.mark.django_db +def test_prm_zwraca_wlasciwe_urle(client, settings): + # Django odda 400 (DisallowedHost) dla hosta spoza ALLOWED_HOSTS — + # bez tego nadpisania test padłby na kliencie testowym, nie na + # widoku, który mamy zweryfikować. + settings.ALLOWED_HOSTS = ["bpp.example.test"] + odp = client.get( + "/.well-known/oauth-protected-resource", HTTP_HOST="bpp.example.test" + ) + assert odp.status_code == 200 + dane = odp.json() + assert dane["resource"] == "http://bpp.example.test/mcp" + assert dane["authorization_servers"] == ["http://bpp.example.test"] + assert dane["scopes_supported"] == ["read"] + + +@pytest.mark.django_db +def test_prm_jest_per_host(client, settings): + """Wielo-domenowość: każda uczelnia widzi swój adres.""" + settings.ALLOWED_HOSTS = ["uczelnia1.localhost", "uczelnia2.localhost"] + a = client.get( + "/.well-known/oauth-protected-resource", HTTP_HOST="uczelnia1.localhost" + ).json() + b = client.get( + "/.well-known/oauth-protected-resource", HTTP_HOST="uczelnia2.localhost" + ).json() + assert a["resource"] != b["resource"] diff --git a/src/oauth_mcp/urls.py b/src/oauth_mcp/urls.py index 1f67b34d5..80c61954a 100644 --- a/src/oauth_mcp/urls.py +++ b/src/oauth_mcp/urls.py @@ -2,7 +2,10 @@ from oauth2_provider import views as oauth2_views from oauth_mcp.views_dcr import DynamicClientRegistrationView -from oauth_mcp.views_metadata import oauth_authorization_server_metadata +from oauth_mcp.views_metadata import ( + oauth_authorization_server_metadata, + oauth_protected_resource_metadata, +) # BEZ `app_name` na tym module! Deklaracja `app_name="oauth_mcp"` zagnieżdżałaby # namespace DOT (`oauth_mcp:oauth2_provider:authorize`) i psuła @@ -33,4 +36,9 @@ oauth_authorization_server_metadata, name="oauth-as-metadata", ), + path( + ".well-known/oauth-protected-resource", + oauth_protected_resource_metadata, + name="oauth-prm", + ), ] diff --git a/src/oauth_mcp/views_metadata.py b/src/oauth_mcp/views_metadata.py index 895d66e6f..15c0a1a5c 100644 --- a/src/oauth_mcp/views_metadata.py +++ b/src/oauth_mcp/views_metadata.py @@ -22,3 +22,21 @@ def oauth_authorization_server_metadata(request): "token_endpoint_auth_methods_supported": ["none"], } ) + + +def oauth_protected_resource_metadata(request): + """RFC 9728 — mówi klientowi MCP, który serwer autoryzacji obsługuje /mcp. + + Poprzedni spec przypisywał ten dokument pakietowi bpp-mcp; odkąd Resource + Serverem jest sama instancja BPP, mieszka on tutaj. URL-e przez + build_absolute_uri — poprawny scheme z SECURE_PROXY_SSL_HEADER, host + per-request (wielo-domenowość). + """ + return JsonResponse( + { + "resource": request.build_absolute_uri("/mcp"), + "authorization_servers": [request.build_absolute_uri("/").rstrip("/")], + "scopes_supported": ["read"], + "bearer_methods_supported": ["header"], + } + ) From 16ca2fccb4bd494915472fa48d8ac783354e1f6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sun, 6 Sep 2026 18:00:29 +0200 Subject: [PATCH 22/29] =?UTF-8?q?feat(mcp=5Fserver):=20strona=20/mcp/=20z?= =?UTF-8?q?=20instrukcj=C4=85=20pod=C5=82=C4=85czenia?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU --- src/django_bpp/urls.py | 1 + .../templates/mcp_server/index.html | 36 +++++++++++++++++++ src/mcp_server/tests/test_strona.py | 26 ++++++++++++++ src/mcp_server/urls.py | 11 ++++++ src/mcp_server/views.py | 21 +++++++++++ 5 files changed, 95 insertions(+) create mode 100644 src/mcp_server/templates/mcp_server/index.html create mode 100644 src/mcp_server/tests/test_strona.py create mode 100644 src/mcp_server/urls.py create mode 100644 src/mcp_server/views.py diff --git a/src/django_bpp/urls.py b/src/django_bpp/urls.py index 20768cd39..946a0b4c3 100644 --- a/src/django_bpp/urls.py +++ b/src/django_bpp/urls.py @@ -68,6 +68,7 @@ def protected_media_serve(request, path, document_root=None): ), path("formdefaults/", include("formdefaults.urls")), path("", include("oauth_mcp.urls")), + path("mcp/", include("mcp_server.urls", namespace="mcp_server")), url(r"^favicon\.ico$", cache_page(60 * 60)(favicon)), path("test_403/", login_required(test_403_view)), path("test_500/", login_required(test_500_view)), diff --git a/src/mcp_server/templates/mcp_server/index.html b/src/mcp_server/templates/mcp_server/index.html new file mode 100644 index 000000000..c8333a6d8 --- /dev/null +++ b/src/mcp_server/templates/mcp_server/index.html @@ -0,0 +1,36 @@ +{# Strona /mcp/ — instrukcja podłączenia klienta AI (dla człowieka). #} +{# Same adresy MCP (/mcp, /mcp/auth) obsługuje warstwa ASGI, patrz #} +{# mcp_server.routing.RouterHttp — ta strona tylko je pokazuje. #} +{% extends "base.html" %} +{% load i18n %} + +{% block extratitle %} + {% trans "Bibliografia w Twoim asystencie AI" %} +{% endblock %} + +{% block content %} +
+
+

{% trans "Bibliografia w Twoim asystencie AI" %}

+ +

+ {% trans "Wklej adres poniżej do klienta AI obsługującego zdalne serwery MCP." %} +

+ +

{% trans "Dostęp publiczny" %}

+

{{ adres_publiczny }}

+

+ {% trans "Bez logowania. Widzi to samo, co publiczna część bibliografii." %} +

+ +

{% trans "Dostęp z logowaniem" %}

+

{{ adres_z_logowaniem }}

+

+ {% trans "Poprosi o zalogowanie w przeglądarce. Odblokowuje dane niepubliczne." %} +

+ +

{% trans "Claude Code" %}

+
claude mcp add bpp --transport http {{ adres_publiczny }}
+
+
+{% endblock %} diff --git a/src/mcp_server/tests/test_strona.py b/src/mcp_server/tests/test_strona.py new file mode 100644 index 000000000..739088f65 --- /dev/null +++ b/src/mcp_server/tests/test_strona.py @@ -0,0 +1,26 @@ +"""Strona /mcp/ — instrukcja podłączenia dla człowieka.""" + +import pytest + + +@pytest.mark.django_db +def test_strona_pokazuje_oba_adresy(client, settings): + # Bez nadpisania ALLOWED_HOSTS Django odda 400 dla obcego Hosta, + # zanim widok zdąży zbudować adresy (patrz test_cache_vary_host.py). + settings.ALLOWED_HOSTS = ["bpp.example.test"] + + odp = client.get("/mcp/", HTTP_HOST="bpp.example.test") + + assert odp.status_code == 200 + tresc = odp.content.decode() + assert "http://bpp.example.test/mcp" in tresc + assert "http://bpp.example.test/mcp/auth" in tresc + + +@pytest.mark.django_db +def test_adresy_sa_per_host(client, settings): + settings.ALLOWED_HOSTS = ["uczelnia1.localhost"] + + a = client.get("/mcp/", HTTP_HOST="uczelnia1.localhost").content.decode() + + assert "uczelnia1.localhost/mcp" in a diff --git a/src/mcp_server/urls.py b/src/mcp_server/urls.py new file mode 100644 index 000000000..4191edb87 --- /dev/null +++ b/src/mcp_server/urls.py @@ -0,0 +1,11 @@ +"""Routing Django dla strony /mcp/ (instrukcja dla człowieka).""" + +from django.urls import path + +from mcp_server.views import StronaMcp + +app_name = "mcp_server" + +urlpatterns = [ + path("", StronaMcp.as_view(), name="strona"), +] diff --git a/src/mcp_server/views.py b/src/mcp_server/views.py new file mode 100644 index 000000000..6bf9555a5 --- /dev/null +++ b/src/mcp_server/views.py @@ -0,0 +1,21 @@ +"""Widoki serwera MCP przeznaczone dla człowieka (nie dla klienta MCP).""" + +from django.views.generic import TemplateView + + +class StronaMcp(TemplateView): + """Instrukcja podłączenia klienta AI — adresy składane per host. + + Adresy MCP (``/mcp`` i ``/mcp/auth``) obsługuje warstwa ASGI + (``mcp_server.routing.RouterHttp``) — ta strona (``/mcp/``, ze + slashem) idzie normalnym trybem przez Django i tylko pokazuje, + dokąd wkleić te adresy w kliencie AI. + """ + + template_name = "mcp_server/index.html" + + def get_context_data(self, **kwargs): + kontekst = super().get_context_data(**kwargs) + kontekst["adres_publiczny"] = self.request.build_absolute_uri("/mcp") + kontekst["adres_z_logowaniem"] = self.request.build_absolute_uri("/mcp/auth") + return kontekst From deb1f22a2be60acdaf77d932b54de4f2c73443e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sun, 6 Sep 2026 18:24:16 +0200 Subject: [PATCH 23/29] =?UTF-8?q?test(mcp=5Fserver):=20regresje=20bezpiecz?= =?UTF-8?q?e=C5=84stwa=20i=20wielo-hostowe=20(Task=2011)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU --- src/mcp_server/tests/test_bezpieczenstwo.py | 93 ++++++++++++++++++++ src/mcp_server/tests/test_dozwolone_hosty.py | 56 ++++++++++++ src/mcp_server/tests/test_wielohost.py | 64 ++++++++++++++ 3 files changed, 213 insertions(+) create mode 100644 src/mcp_server/tests/test_bezpieczenstwo.py create mode 100644 src/mcp_server/tests/test_dozwolone_hosty.py create mode 100644 src/mcp_server/tests/test_wielohost.py diff --git a/src/mcp_server/tests/test_bezpieczenstwo.py b/src/mcp_server/tests/test_bezpieczenstwo.py new file mode 100644 index 000000000..15e3d483c --- /dev/null +++ b/src/mcp_server/tests/test_bezpieczenstwo.py @@ -0,0 +1,93 @@ +"""Regresje bezpieczeństwa warstwy /mcp (spec §7).""" + +import httpx +import pytest +from bpp_mcp.client import BppError + +from mcp_server.klient import BppClientInProcess +from mcp_server.kontekst import DaneZadania, dane_zadania +from mcp_server.tests.utils import uruchom + + +def _dane(host="bpp.example.test", bearer=None): + return DaneZadania( + host=host, scheme="https", ip="198.51.100.9", bearer=bearer, deadline=None + ) + + +def _przechwytujacy(): + zebrane = [] + + def handler(request): + zebrane.append(request) + return httpx.Response(200, json={"ok": 1}) + + return httpx.MockTransport(handler), zebrane + + +def test_cookie_nie_trafia_do_zadania_wewnetrznego(): + """Cookie uwierzytelniłoby sesją przez SessionAuthentication — z pełnymi + uprawnieniami, bez zgody, bez scope i bez revoke (spec §7.1).""" + transport, zebrane = _przechwytujacy() + + async def scenariusz(): + zeton = dane_zadania.set(_dane()) + try: + klient = BppClientInProcess(transport=transport) + await klient.get_json("autor/1/") + finally: + dane_zadania.reset(zeton) + + uruchom(scenariusz) + assert "cookie" not in {k.lower() for k in zebrane[0].headers} + + +def test_basic_nie_trafia_do_zadania_wewnetrznego(): + """BasicAuthentication jest trzecia w DEFAULT_AUTHENTICATION_CLASSES, + a ApiReadOnlyForBearerMiddleware sprawdza tylko prefiks 'bearer ', więc + dla Basica warstwa read-only NIE istnieje (spec §7.1).""" + transport, zebrane = _przechwytujacy() + + async def scenariusz(): + zeton = dane_zadania.set(_dane(bearer=None)) + try: + klient = BppClientInProcess(transport=transport) + await klient.get_json("autor/1/") + finally: + dane_zadania.reset(zeton) + + uruchom(scenariusz) + naglowek = zebrane[0].headers.get("authorization", "") + assert not naglowek.lower().startswith("basic") + + +def test_sciezka_spoza_api_v1_odrzucona(): + transport, _ = _przechwytujacy() + + async def scenariusz(): + zeton = dane_zadania.set(_dane()) + try: + klient = BppClientInProcess(transport=transport) + with pytest.raises(BppError): + await klient.get_json("https://bpp.example.test/admin/") + finally: + dane_zadania.reset(zeton) + + uruchom(scenariusz) + + +def test_obcy_host_w_url_odrzucony(): + """_full_url przyjmuje URL-e bezwzględne wprost, a paginacyjne `next` je + niosą — kontrola musi obejmować host, nie tylko prefiks (spec §7.4).""" + transport, _ = _przechwytujacy() + + async def scenariusz(): + zeton = dane_zadania.set(_dane(host="bpp.a.test")) + try: + klient = BppClientInProcess(transport=transport) + with pytest.raises(BppError): + await klient.get_json("https://bpp.zly.test/api/v1/autor/1/") + finally: + dane_zadania.reset(zeton) + + uruchom(scenariusz) diff --git a/src/mcp_server/tests/test_dozwolone_hosty.py b/src/mcp_server/tests/test_dozwolone_hosty.py new file mode 100644 index 000000000..5a23c944d --- /dev/null +++ b/src/mcp_server/tests/test_dozwolone_hosty.py @@ -0,0 +1,56 @@ +"""Testy jednostkowe ``_dozwolone_hosty()`` (spec §6.1, recenzja Tasku 7). + +Funkcja tłumaczy ``ALLOWED_HOSTS`` Django na allowlistę hostów, którą +rozumie ``TransportSecuritySettings`` SDK MCP — semantyka OBU list jest +inna (SDK dopasowuje dokładnie albo wzorcem ``host:*``; Django rozumie +``*`` i wiodącą ``.domenę``), więc błąd tutaj otwiera albo zamyka DNS +rebinding protection na całej ``/mcp``. +""" + +from mcp_server.aplikacja import _dozwolone_hosty + + +def test_gwiazdka_wylacza_sprawdzanie(settings): + """SDK nie ma odpowiednika ``*`` — jedyny bezpieczny przekład to pusta + lista, która w ``build_application`` wyłącza + ``enable_dns_rebinding_protection`` (patrz wywołanie w ``aplikacja.py``).""" + settings.ALLOWED_HOSTS = ["*"] + assert _dozwolone_hosty() == [] + + +def test_gwiazdka_wygrywa_niezaleznie_od_pozycji(settings): + """``*`` gdziekolwiek na liście wyłącza sprawdzanie w całości — funkcja + zwraca od razu, nawet gdy przed nim zdążyła dodać inne wpisy.""" + settings.ALLOWED_HOSTS = ["bpp.realny.test", "*"] + assert _dozwolone_hosty() == [] + + +def test_zwykly_host_dokladny_i_z_gwiazdka_portu(settings): + """Zwykły wpis daje DWA warianty: dokładny (bez portu) i ``host:*`` + (z dowolnym portem) — SDK nie ma osobnej semantyki „dowolny port".""" + settings.ALLOWED_HOSTS = ["bpp.przyklad.test"] + hosty = _dozwolone_hosty() + assert hosty == ["bpp.przyklad.test", "bpp.przyklad.test:*"] + + +def test_wiele_hostow_daje_wpisy_dla_kazdego(settings): + settings.ALLOWED_HOSTS = ["bpp.a.test", "bpp.b.test"] + hosty = _dozwolone_hosty() + assert hosty == [ + "bpp.a.test", + "bpp.a.test:*", + "bpp.b.test", + "bpp.b.test:*", + ] + + +def test_wiodaca_kropka_django_bez_wiodacej_kropki_w_wyniku(settings): + """Django-owe ``.domena`` (dopasowuje domenę i WSZYSTKIE subdomeny) SDK + nie rozumie wprost — funkcja ucina kropkę, więc subdomeny same nie + przejdą (SDK dopasowuje dokładnie), ale przynajmniej goła domena + działa, zamiast nie dopasować NICZEGO (kropka na początku nie pasowałaby + do żadnego realnego nagłówka ``Host``).""" + settings.ALLOWED_HOSTS = [".przyklad.test"] + hosty = _dozwolone_hosty() + assert hosty == ["przyklad.test", "przyklad.test:*"] + assert not any(h.startswith(".") for h in hosty) diff --git a/src/mcp_server/tests/test_wielohost.py b/src/mcp_server/tests/test_wielohost.py new file mode 100644 index 000000000..aa3c3585e --- /dev/null +++ b/src/mcp_server/tests/test_wielohost.py @@ -0,0 +1,64 @@ +"""Wewnętrzne żądanie musi nieść Host zewnętrznego — inaczej Uczelnia=None, +BramkaApiV1 przepuszcza bezwarunkowo i wyciekają ukryte statusy (spec §7.2).""" + +import pytest +from bpp_mcp.client import BppError + +from mcp_server.klient import BppClientInProcess +from mcp_server.kontekst import DaneZadania, dane_zadania +from mcp_server.tests.utils import uruchom + +pytestmark = pytest.mark.django_db(transaction=True) + + +def _pobierz(host, sciezka="uczelnia/"): + async def scenariusz(): + zeton = dane_zadania.set( + DaneZadania( + host=host, scheme="http", ip="198.51.100.9", bearer=None, deadline=None + ) + ) + try: + klient = BppClientInProcess() + return await klient.get_json(sciezka) + finally: + dane_zadania.reset(zeton) + + return uruchom(scenariusz) + + +def test_kazdy_host_widzi_swoja_uczelnie( + settings, uczelnia1, uczelnia2, wydawnictwo_ciagle, przed_korekta +): + """``/api/v1/uczelnia/`` NIE jest tu użyty — ``UczelniaViewSet.queryset`` + to gołe ``Uczelnia.objects.all()``, bez scoping po Site/Host (jednostki + i uczelnie „obce" muszą być widoczne globalnie, bo autorzy afiliują się + między instytucjami we wspólnej instalacji — patrz komentarz w + ``JednostkaViewSet``). Sprawdzone empirycznie: oba hosty dostają + IDENTYCZNĄ listę obu uczelni. + + Właściwością, która faktycznie różni się per Host, jest filtr + ``ukryte_statusy("api")`` z ``UkryjStatusyKorektyMixin`` + (``api_v1/viewsets/common.py``) — on właśnie rozstrzyga uczelnię przez + ``Uczelnia.objects.get_for_request(self.request)``, czyli przez Host + żądania. Ukrywamy status korekty rekordu WYŁĄCZNIE dla ``uczelnia1``: + host ``uczelnia1.localhost`` musi rekord wykluczyć, a + ``uczelnia2.localhost`` (inna uczelnia, inna konfiguracja) — nie. + """ + settings.ALLOWED_HOSTS = ["uczelnia1.localhost", "uczelnia2.localhost"] + wydawnictwo_ciagle.status_korekty = przed_korekta + wydawnictwo_ciagle.save() + uczelnia1.ukryj_status_korekty_set.create(status_korekty=przed_korekta) + + a = _pobierz("uczelnia1.localhost", "wydawnictwo_ciagle/") + b = _pobierz("uczelnia2.localhost", "wydawnictwo_ciagle/") + assert a["count"] != b["count"] + + +def test_wylaczone_api_odmawia_takze_przez_mcp(settings, uczelnia1): + """Dowód, że BramkaApiV1 działa na tej ścieżce.""" + settings.ALLOWED_HOSTS = ["uczelnia1.localhost"] + uczelnia1.api_v1_wlaczone = False + uczelnia1.save(update_fields=["api_v1_wlaczone"]) + with pytest.raises(BppError): + _pobierz("uczelnia1.localhost") From bbe863d6e98452343b52393cd4c90a4b706250b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sun, 6 Sep 2026 18:43:51 +0200 Subject: [PATCH 24/29] =?UTF-8?q?fix(mcp=5Fserver):=20docstringi=20test=5F?= =?UTF-8?q?bezpieczenstwo.py=20m=C3=B3wi=C4=85=20prawd=C4=99=20o=20mechani?= =?UTF-8?q?zmie?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU --- src/mcp_server/tests/test_bezpieczenstwo.py | 50 ++++++++++++++++++--- 1 file changed, 45 insertions(+), 5 deletions(-) diff --git a/src/mcp_server/tests/test_bezpieczenstwo.py b/src/mcp_server/tests/test_bezpieczenstwo.py index 15e3d483c..e4874306f 100644 --- a/src/mcp_server/tests/test_bezpieczenstwo.py +++ b/src/mcp_server/tests/test_bezpieczenstwo.py @@ -26,8 +26,21 @@ def handler(request): def test_cookie_nie_trafia_do_zadania_wewnetrznego(): - """Cookie uwierzytelniłoby sesją przez SessionAuthentication — z pełnymi - uprawnieniami, bez zgody, bez scope i bez revoke (spec §7.1).""" + """Regresja na NIEOBECNOŚĆ forwardingu, nie na filtrowanie nagłówków. + + Żądanie wewnętrzne jest KONSTRUOWANE OD ZERA (nowy ``httpx.AsyncClient`` + w ``BppClientInProcess.__init__``, bez jara ciasteczek, bez kopiowania + nagłówków zewnętrznego żądania) — nie ma w kodzie ŻADNEJ ścieżki + (``cookies=`` czy inny mechanizm), którą ciasteczko mogłoby dotrzeć do + tego żądania. To silniejsza gwarancja niż „filtr wycina Cookie" — nie ma + czego filtrować. Test złapie regresję dokładnie wtedy, gdyby ktoś kiedyś + dopisał przekazywanie nagłówków/ciasteczek zewnętrznego żądania. + + Dlaczego to ma znaczenie, GDYBY taki forwarding się pojawił: Cookie + uwierzytelniłoby sesją przez ``SessionAuthentication`` (druga w + ``DEFAULT_AUTHENTICATION_CLASSES``) — z pełnymi uprawnieniami, bez + zgody, bez scope i bez możliwości revoke (spec §7.1). + """ transport, zebrane = _przechwytujacy() async def scenariusz(): @@ -43,9 +56,36 @@ async def scenariusz(): def test_basic_nie_trafia_do_zadania_wewnetrznego(): - """BasicAuthentication jest trzecia w DEFAULT_AUTHENTICATION_CLASSES, - a ApiReadOnlyForBearerMiddleware sprawdza tylko prefiks 'bearer ', więc - dla Basica warstwa read-only NIE istnieje (spec §7.1).""" + """Regresja na NIEOBECNOŚĆ forwardingu, nie na strażnika TrybAuth. + + Dwa NIEZALEŻNE powody, dla których Basic tu nie trafi: + + 1. ``BppClient._auth_kwargs`` ma jawną gałąź + ``if self._tryb_auth is TrybAuth.W_PROCESIE: return {}`` — Basic jest + tam świadomie pomijany. + 2. ALE nawet gdyby TĘ gałąź wycięto, Basic i tak by nie trafił: kod + trafiłby do gałęzi ``if self._auth_tuple: return {"auth": ...}``, + a ``BppClientInProcess.__init__`` buduje + ``Config(base_url=..., transport="stdio")`` i NIGDY nie ustawia + ``basic_auth`` — ``config.auth_tuple`` jest w tej konstrukcji zawsze + ``None``. Basic nie ma więc czym trafić do żądania, niezależnie od + strażnika (zweryfikowane mutacyjnie: wycięcie gałęzi 1. NIE psuje + tego testu). + + Ten test pilnuje więc powodu 2. — konstrukcji ``Config`` bez + ``basic_auth`` — a NIE gałęzi ``TrybAuth.W_PROCESIE``. UWAGA na + przyszłość: gdyby ktoś kiedyś dopisał ``basic_auth=`` do ``Config`` w + ``mcp_server/klient.py`` (np. z env), TEN test przestanie wystarczać — + trzeba będzie dodać osobny test, który faktycznie ćwiczy gałąź 1. + (np. skonfigurowany ``basic_auth`` + wycięta gałąź 1. → Basic w + nagłówku). + + Dlaczego to ma znaczenie, GDYBY Basic trafił do żądania: + ``BasicAuthentication`` jest trzecia w ``DEFAULT_AUTHENTICATION_CLASSES``, + a ``ApiReadOnlyForBearerMiddleware`` sprawdza WYŁĄCZNIE prefiks + ``bearer ``, więc dla Basica warstwa read-only w ogóle nie istnieje + (spec §7.1). + """ transport, zebrane = _przechwytujacy() async def scenariusz(): From b5089bb9a995f344bd99b288bb110988a78f011a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sun, 6 Sep 2026 19:07:51 +0200 Subject: [PATCH 25/29] =?UTF-8?q?feat(mcp=5Fserver):=20raportowanie=20b?= =?UTF-8?q?=C5=82=C4=99d=C3=B3w=20narz=C4=99dzi,=20healthcheck,=20newsfrag?= =?UTF-8?q?ment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU --- docs/deweloper/mapa-kodu.md | 4 + .../newsfragments/mcp-hostowany.feature.rst | 4 + src/mcp_server/aplikacja.py | 57 +++++++++++- src/mcp_server/tests/test_rollbar.py | 88 +++++++++++++++++++ src/mcp_server/tests/test_zdrowie.py | 42 +++++++++ src/mcp_server/zdrowie.py | 25 ++++++ 6 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 src/bpp/newsfragments/mcp-hostowany.feature.rst create mode 100644 src/mcp_server/tests/test_rollbar.py create mode 100644 src/mcp_server/tests/test_zdrowie.py create mode 100644 src/mcp_server/zdrowie.py diff --git a/docs/deweloper/mapa-kodu.md b/docs/deweloper/mapa-kodu.md index 9107fe645..54f414765 100644 --- a/docs/deweloper/mapa-kodu.md +++ b/docs/deweloper/mapa-kodu.md @@ -304,6 +304,8 @@ graph LR | `notifications/` | WebSocket notifications | `Notification`, `NotificationsConsumer` | | `ranking_autorow/` | Author rankings | Ranking views and calculations | | `nowe_raporty/` | Modern reporting interface | Report forms and exports | +| `mcp_server/` | Hosted MCP server for AI assistants (wraps `bpp-mcp` in-process) | `KontekstZadania`, `StartMcp` | +| `oauth_mcp/` | OAuth 2.1 auth server backing `/mcp/auth` (DCR + PKCE) | — (no own models) | --- @@ -511,6 +513,8 @@ from bpp.admin.helpers.fieldsets import ( | `/api/v1/` | REST API | Read-only API | | `/multiseek/` | Multiseek | Advanced search | | `/pbn_import/` | ImportDashboard | PBN import interface | +| `/mcp/` | StronaMcp | Human-readable MCP connection instructions | +| `/mcp`, `/mcp/auth` | `mcp_server.routing.RouterHttp` (ASGI, not Django URLconf) | Streamable-HTTP MCP endpoint — public / OAuth-required | --- diff --git a/src/bpp/newsfragments/mcp-hostowany.feature.rst b/src/bpp/newsfragments/mcp-hostowany.feature.rst new file mode 100644 index 000000000..c16bf1570 --- /dev/null +++ b/src/bpp/newsfragments/mcp-hostowany.feature.rst @@ -0,0 +1,4 @@ +Serwis wystawia własny serwer MCP pod adresem ``/mcp`` (publiczny) oraz +``/mcp/auth`` (z logowaniem OAuth). Asystenci AI obsługujący zdalne serwery +MCP łączą się jednym adresem — bez instalowania czegokolwiek i bez podawania +adresu uczelni. Instrukcja podłączenia jest na stronie ``/mcp/``. diff --git a/src/mcp_server/aplikacja.py b/src/mcp_server/aplikacja.py index 631bab95e..ddf831dce 100644 --- a/src/mcp_server/aplikacja.py +++ b/src/mcp_server/aplikacja.py @@ -2,8 +2,10 @@ from __future__ import annotations +import functools from contextlib import asynccontextmanager +import rollbar from bpp_mcp import register_tools from django.conf import settings from mcp.server.mcpserver import MCPServer @@ -48,6 +50,59 @@ def _dozwolone_hosty() -> list[str]: return hosty +def _z_raportowaniem(serwer: MCPServer) -> MCPServer: + """Owiń ``serwer.tool()`` raportowaniem wyjątków narzędzi do Rollbara. + + ``MCPServer._handle_call_tool`` łapie KAŻDY wyjątek handlera i zamienia + go w ``CallToolResult(is_error=True)`` (patrz + ``mcp.server.mcpserver.server``) — na tym poziomie zostaje po nim tylko + ``logger.exception(...)``. Nic z tego nie dociera do warstwy ASGI, więc + ``CustomRollbarNotifierMiddleware`` (który łapie wyjątki Django) nigdy nie + zobaczy awarii narzędzia — musimy zgłosić ją sami, ZANIM SDK ją pochłonie + (spec §7.5). + + Zweryfikowane w zainstalowanym ``mcp`` (2.x), nie zgadywane: + + * ``MCPServer.tool()`` to DEKORATOR-FABRYKA — ``tool(**kw)`` zwraca + ``decorator``, a ``decorator(fn)`` REJESTRUJE narzędzie natychmiast + (``self.add_tool(fn, ...)``) i oddaje ``fn`` bez zmian. Rejestracja + dzieje się więc w momencie dekorowania, nie leniwie — stąd podmiana + ``serwer.tool`` musi zdążyć PRZED wywołaniem ``register_tools``. + * Schemat argumentów i wykrycie parametru ``Context`` liczy + ``Tool.from_function`` na PODANEJ (owinięta) funkcji przez + ``inspect.signature(fn, eval_str=True)`` i ``typing.get_type_hints(fn)`` + — obie te funkcje stdlib jawnie idą po łańcuchu ``__wrapped__``, więc + ``functools.wraps`` na wewnętrznym ``wrapper`` wystarcza, by dostały + oryginalny sygnaturę/adnotacje (i ``__globals__`` oryginału — istotne, + bo ``bpp_mcp.tools``/``server`` mają ``from __future__ import + annotations``, więc adnotacja ``Context`` jest stringiem do + wyliczenia). Potwierdzone w praktyce testem + ``test_context_i_schemat_przetrwaly_wrapper`` — bez tego wrapper + zarejestrowałby ``ctx`` jako zwykły, wymagany parametr wejściowy + zamiast wstrzykiwanego kontekstu. + """ + oryginalny = serwer.tool + + def tool(*args, **kwargs): + dekorator = oryginalny(*args, **kwargs) + + def opakuj(fn): + @functools.wraps(fn) + async def wrapper(*a, **kw): + try: + return await fn(*a, **kw) + except Exception: + rollbar.report_exc_info() + raise + + return dekorator(wrapper) + + return opakuj + + serwer.tool = tool + return serwer + + def build_application(): """Zbuduj aplikację ASGI serwera MCP i jej ``StartMcp``. @@ -61,7 +116,7 @@ async def lifespan(_serwer): yield KontekstZadania() serwer = MCPServer("bpp", version="1", lifespan=lifespan) - register_tools(serwer) + register_tools(_z_raportowaniem(serwer)) hosty = _dozwolone_hosty() bezpieczenstwo = TransportSecuritySettings( diff --git a/src/mcp_server/tests/test_rollbar.py b/src/mcp_server/tests/test_rollbar.py new file mode 100644 index 000000000..861a94527 --- /dev/null +++ b/src/mcp_server/tests/test_rollbar.py @@ -0,0 +1,88 @@ +"""Awarie narzędzi MCP muszą trafiać do Rollbara (spec §7.5). + +Kontekst: ``MCPServer._handle_call_tool`` (protokołowy handler, wołany +faktycznie przez sesję http) łapie KAŻDY wyjątek narzędzia i zamienia go w +``CallToolResult(is_error=True)`` — dociera do niego jedynie +``logger.exception(...)``, nic z tego nie trafia do warstwy ASGI, więc +``CustomRollbarNotifierMiddleware`` (łapie wyjątki Django) nigdy nie zobaczy +awarii narzędzia. ``_z_raportowaniem`` w ``aplikacja.py`` musi więc zgłosić +wyjątek do Rollbara PRZED tym, jak SDK go pochłonie. + +``serwer.call_tool(...)`` (publiczne API ``MCPServer``, użyte tu) NIE łapie +wyjątków — podnosi ``UnexpectedToolError``/``ToolError`` (patrz +``mcp.server.mcpserver.tools.base.Tool.run``). To wystarcza do sprawdzenia +raportowania: nasz wrapper zgłasza wyjątek do Rollbara WCZEŚNIEJ, w środku +opakowanej funkcji narzędzia — zanim jakikolwiek poziom SDK zdąży go złapać +czy przekonwertować. +""" + +from unittest.mock import Mock + +import bpp_mcp.tools as bpp_tools +import pytest +from mcp.server.mcpserver.exceptions import UnexpectedToolError + +from mcp_server import aplikacja +from mcp_server.aplikacja import build_application +from mcp_server.tests.utils import uruchom + + +async def _wybuchaj(*_a, **_kw): + raise RuntimeError("awaria-testowa-narzedzia") + + +def test_wyjatek_narzedzia_trafia_do_rollbara(monkeypatch): + """Wywołanie narzędzia, które rzuca wyjątek, musi zawołać + ``rollbar.report_exc_info`` — bez tego awarie narzędzi są niewidoczne + w monitoringu (patrz docstring modułu).""" + mock_rollbar = Mock() + monkeypatch.setattr(aplikacja, "rollbar", mock_rollbar) + # djangoql_schema nie potrzebuje ani ``ctx``, ani klienta HTTP — najlżejsze + # narzędzie do wywołania bez stawiania pełnego żądania/klienta w procesie. + monkeypatch.setattr(bpp_tools, "djangoql_schema", _wybuchaj) + + mcp_app, _ = build_application() + serwer = mcp_app.state.serwer_mcp + + async def scenariusz(): + with pytest.raises(UnexpectedToolError): + await serwer.call_tool("djangoql_schema", {"model": "rekord"}) + + uruchom(scenariusz) + + mock_rollbar.report_exc_info.assert_called_once() + + +def test_narzedzie_bez_wyjatku_nie_raportuje(monkeypatch): + """Kontrolny brzeg: powodzenie NIE ma trafiać do Rollbara.""" + mock_rollbar = Mock() + monkeypatch.setattr(aplikacja, "rollbar", mock_rollbar) + + mcp_app, _ = build_application() + serwer = mcp_app.state.serwer_mcp + + async def scenariusz(): + return await serwer.call_tool("djangoql_schema", {"model": "rekord"}) + + uruchom(scenariusz) + + mock_rollbar.report_exc_info.assert_not_called() + + +def test_context_i_schemat_przetrwaly_wrapper(): + """Regresja na ryzyko opisane w docstringu ``_z_raportowaniem``: gdyby + ``functools.wraps`` nie wystarczał do zachowania sygnatury/adnotacji + oryginalnej funkcji narzędzia, ``ctx: Context`` przestałby być wykrywany + jako parametr wstrzykiwany i stałby się (niewykonalnym) polem schematu + wejściowego — a schemat straciłby prawdziwe parametry narzędzia.""" + mcp_app, _ = build_application() + serwer = mcp_app.state.serwer_mcp + + narzedzie = serwer._tool_manager.get_tool("szukaj_publikacji") + assert narzedzie is not None + assert narzedzie.context_kwarg == "ctx" + + wlasciwosci = narzedzie.parameters["properties"] + assert "ctx" not in wlasciwosci + assert set(wlasciwosci) == {"q", "rok_od", "rok_do", "limit"} + assert narzedzie.parameters["required"] == ["q"] diff --git a/src/mcp_server/tests/test_zdrowie.py b/src/mcp_server/tests/test_zdrowie.py new file mode 100644 index 000000000..5606759d1 --- /dev/null +++ b/src/mcp_server/tests/test_zdrowie.py @@ -0,0 +1,42 @@ +"""``stan_mcp()`` odróżnia „proces działa" od „endpoint MCP działa" (spec §10.3). + +Testy podstawiają FAŁSZYWY moduł pod ``django_bpp.asgi`` w ``sys.modules`` — +import prawdziwego modułu wykonuje ``build_application()`` przy imporcie +i ta jedna instancja żyje tyle, co proces (patrz komentarz w +``test_asgi_montaz.py``); podstawienie unika triggerowania tego kosztu/efektu +w każdym teście tego pliku. +""" + +import sys +import types + +from mcp_server.zdrowie import stan_mcp + + +class _FalszywyStart: + def __init__(self, wystartowany: bool, zywy: bool) -> None: + self.wystartowany = wystartowany + self.zywy = zywy + + +def _podstaw_asgi(monkeypatch, start) -> None: + falszywy_modul = types.ModuleType("django_bpp.asgi") + falszywy_modul._mcp_start = start + monkeypatch.setitem(sys.modules, "django_bpp.asgi", falszywy_modul) + + +def test_wystartowany_i_zywy(monkeypatch): + _podstaw_asgi(monkeypatch, _FalszywyStart(wystartowany=True, zywy=True)) + assert stan_mcp() == {"wystartowany": True, "zywy": True} + + +def test_zatruta_grupa_zadan_wystartowany_ale_nie_zywy(monkeypatch): + """Grupa zadań zdechła po zatruciu wyjątkiem dziecka — proces wciąż + żyje, ale ``/mcp`` już nie odpowie, aż do recyklingu workera.""" + _podstaw_asgi(monkeypatch, _FalszywyStart(wystartowany=True, zywy=False)) + assert stan_mcp() == {"wystartowany": True, "zywy": False} + + +def test_jeszcze_nie_wystartowany(monkeypatch): + _podstaw_asgi(monkeypatch, _FalszywyStart(wystartowany=False, zywy=False)) + assert stan_mcp() == {"wystartowany": False, "zywy": False} diff --git a/src/mcp_server/zdrowie.py b/src/mcp_server/zdrowie.py new file mode 100644 index 000000000..bce80e57d --- /dev/null +++ b/src/mcp_server/zdrowie.py @@ -0,0 +1,25 @@ +"""Stan endpointu MCP dla monitoringu. + +Grupa zadań menedżera sesji MCP (``StartMcp``) może zostać zatruta wyjątkiem +dziecka — proces wtedy żyje, ale każde ``/mcp`` zwraca błąd aż do recyklingu +workera. Healthcheck musi więc odróżniać „proces działa" od „endpoint MCP +działa" (spec §10.3): drugie jest ostrzejszym warunkiem od pierwszego. +""" + +from __future__ import annotations + + +def stan_mcp() -> dict: + """Zwróć stan startu serwera MCP z ``StartMcp`` wystawionego przez ASGI. + + Import wewnątrz funkcji (nie na szczycie modułu): ``django_bpp.asgi`` + buduje aplikację MCP przy imporcie (``build_application()``), więc + importowanie go na szczycie tego modułu ściągnęłoby ten koszt do każdego + miejsca, które tylko chce zaimportować ``stan_mcp`` (np. testy). + """ + from django_bpp.asgi import _mcp_start + + return { + "wystartowany": _mcp_start.wystartowany, + "zywy": _mcp_start.zywy, + } From cff083b0f923285a7e02d1bb03961966af5fb1da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sun, 6 Sep 2026 20:57:23 +0200 Subject: [PATCH 26/29] =?UTF-8?q?fix(mcp=5Fserver):=20fala=20naprawcza=20p?= =?UTF-8?q?o=20finalnej=20recenzji=20ga=C5=82=C4=99zi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU --- src/mcp_server/aplikacja.py | 36 ++++ src/mcp_server/auth.py | 18 +- src/mcp_server/kontekst.py | 42 +++++ src/mcp_server/routing.py | 123 ++++++++++++-- src/mcp_server/start.py | 49 ++++++ src/mcp_server/tests/test_auth.py | 47 +++++- src/mcp_server/tests/test_bramka_uczelni.py | 112 +++++++++++++ src/mcp_server/tests/test_dozwolone_hosty.py | 27 +++ src/mcp_server/tests/test_e2e.py | 133 ++++++++++++--- src/mcp_server/tests/test_logowanie.py | 123 ++++++++++++++ src/mcp_server/tests/test_rollbar.py | 66 +++++++- src/mcp_server/tests/test_routing.py | 50 +++++- src/mcp_server/tests/test_start.py | 69 ++++++++ src/mcp_server/tests/test_zdrowie.py | 167 +++++++++++++++---- src/mcp_server/uczelnia.py | 92 ++++++++++ src/mcp_server/zdrowie.py | 42 +++-- 16 files changed, 1109 insertions(+), 87 deletions(-) create mode 100644 src/mcp_server/tests/test_bramka_uczelni.py create mode 100644 src/mcp_server/tests/test_logowanie.py create mode 100644 src/mcp_server/uczelnia.py diff --git a/src/mcp_server/aplikacja.py b/src/mcp_server/aplikacja.py index ddf831dce..ee011cb60 100644 --- a/src/mcp_server/aplikacja.py +++ b/src/mcp_server/aplikacja.py @@ -7,6 +7,7 @@ import rollbar from bpp_mcp import register_tools +from bpp_mcp.client import BppError from django.conf import settings from mcp.server.mcpserver import MCPServer from mcp.server.transport_security import TransportSecuritySettings @@ -40,6 +41,21 @@ def _dozwolone_hosty() -> list[str]: ``.domena`` i ``*`` nie działają (spec §6.1). ``*`` w ALLOWED_HOSTS (używane w testach) mapujemy na wyłączenie sprawdzania, bo nie ma dla niego odpowiednika. + + **Ta lista NIE jest kontrolą wielotenantową** i nie próbuje nią być. + ``TransportSecuritySettings`` chroni przed DNS rebindingiem — przed + przeglądarką namówioną na wysłanie żądania z cudzą nazwą. Tożsamość + uczelni rozstrzyga ``mcp_server.uczelnia`` PER ŻĄDANIE, i tam jest + fail-closed (spec §7.2). + + Dlaczego nie zawężamy tej listy do hostów mapujących się na ``Site``: + funkcja biegnie przy budowie aplikacji, czyli przy imporcie + ``django_bpp.asgi``, a import modułu ASGI nie może zależeć od dostępnej + bazy. Zawężenie do samego ``DJANGO_BPP_HOSTNAMES`` (bez bazy) też nie + rozwiązywałoby problemu: hostname w konfiguracji nie dowodzi istnienia + ``Site`` ani powiązanej ``Uczelnia`` — dałoby więc poczucie + bezpieczeństwa bez samego bezpieczeństwa, a przy okazji rozjechałoby + ``ALLOWED_HOSTS`` z allowlistą MCP w dev i w testach. """ hosty: list[str] = [] for wpis in settings.ALLOWED_HOSTS: @@ -80,6 +96,21 @@ def _z_raportowaniem(serwer: MCPServer) -> MCPServer: ``test_context_i_schemat_przetrwaly_wrapper`` — bez tego wrapper zarejestrowałby ``ctx`` jako zwykły, wymagany parametr wejściowy zamiast wstrzykiwanego kontekstu. + + **Co NIE jest raportowane: ``BppError``.** W ``bpp-mcp`` to normalny kanał + komunikatów do użytkownika, nie awaria — 401 anonima przy DjangoQL, 404 + nieistniejącej encji, odrzucony argument, przekroczony budżet czasu. Na + nieuwierzytelnionym, publicznym endpoincie raportowanie ich znaczyłoby, że + dowolna osoba z internetu wyczerpuje kwotę Rollbara jednym ``curl``-em + w pętli i topi realne alerty (zmierzone przez recenzenta: 4 zgłoszenia na + 4 wywołania). Okno maintenance albo niedokończony kreator dawałyby + zgłoszenie z KAŻDEGO wywołania. + + Wyjątek od wyjątku: ``BppError`` ze statusem 5xx. Ten status pochodzi + z odpowiedzi NASZEJ aplikacji Django na żądanie wewnętrzne — czyli mówi + o awarii po naszej stronie, nie o błędzie pytającego. Takie zgłoszenie + jest wprost tym, co monitoring ma zobaczyć, a wywołać je może wyłącznie + faktyczna awaria serwisu (użytkownik nie ma jak wymusić 5xx z /api/v1/). """ oryginalny = serwer.tool @@ -91,6 +122,11 @@ def opakuj(fn): async def wrapper(*a, **kw): try: return await fn(*a, **kw) + except BppError as exc: + status = getattr(exc, "status_code", None) + if status is not None and status >= 500: + rollbar.report_exc_info() + raise except Exception: rollbar.report_exc_info() raise diff --git a/src/mcp_server/auth.py b/src/mcp_server/auth.py index cb67d9d52..9890e2f84 100644 --- a/src/mcp_server/auth.py +++ b/src/mcp_server/auth.py @@ -13,6 +13,8 @@ from asgiref.sync import sync_to_async from django.db import close_old_connections +from mcp_server.kontekst import schemat_zadania + WYMAGANY_SCOPE = "read" @@ -72,9 +74,19 @@ async def __call__(self, scope, receive, send): await self._aplikacja(scope, receive, send) async def _odmow(self, scope, send) -> None: - naglowki = {k.lower(): v for k, v in scope.get("headers", [])} - host = naglowki.get(b"host", b"localhost").decode() - schemat = "https" if scope.get("scheme") in ("https", "wss") else "http" + naglowki = { + k.lower().decode(): v.decode(errors="replace") + for k, v in scope.get("headers", []) + } + host = naglowki.get("host", "localhost") + # NIE ``scope["scheme"]``: nginx stoi w osobnym kontenerze, a + # ``docker/appserver/gunicorn_conf.py`` nie ustawia + # ``forwarded_allow_ips`` (default gunicorna to 127.0.0.1), więc + # uvicorn NIE ufa nagłówkom proxy i zostawia scheme="http". PRM + # wskazywałby wtedy na ``http://`` i psuł discovery OAuth — czyli + # dokładnie to, po co ten adres istnieje. Nagłówek czytamy tak samo + # jak ``RouterHttp._dane`` (jedno źródło prawdy: ``schemat_zadania``). + schemat = schemat_zadania(naglowki, scope.get("scheme", "http")) prm = f"{schemat}://{host}/.well-known/oauth-protected-resource" cialo = json.dumps( { diff --git a/src/mcp_server/kontekst.py b/src/mcp_server/kontekst.py index 671b0f3c1..7d5ae15bb 100644 --- a/src/mcp_server/kontekst.py +++ b/src/mcp_server/kontekst.py @@ -9,9 +9,12 @@ from __future__ import annotations +from collections.abc import Mapping from contextvars import ContextVar from dataclasses import dataclass +from django.conf import settings + @dataclass(frozen=True) class DaneZadania: @@ -42,3 +45,42 @@ def biezace() -> DaneZadania: "uruchomiona albo ContextVar został zresetowany za wcześnie." ) return dane + + +def _naglowek_schematu() -> tuple[str, str]: + """Nazwa nagłówka niosącego prawdziwy schemat i wartość znacząca „https”. + + Czytamy z ``SECURE_PROXY_SSL_HEADER``, a NIE z zaszytego na sztywno + ``X-Forwarded-Proto``, bo w tym repozytorium te dwie rzeczy to nie to samo: + ``settings/base.py`` deklaruje ``HTTP_X_FORWARDED_PROTOCOL``, a + ``settings/production.py`` — ``HTTP_X_FORWARDED_PROTO``. Zaszycie jednej + z tych nazw sprawiłoby, że warstwa MCP rozjeżdża się z Django dokładnie + w jednym z dwóch środowisk — po cichu, bo objawem jest tylko zły schemat + w składanych adresach (PRM w ``WWW-Authenticate``, ``base_url`` klienta). + + Klucz ASGI jest małymi literami i z myślnikami, więc tłumaczymy nazwę + z konwencji ``request.META`` (``HTTP_X_FORWARDED_PROTO``) na nagłówkową + (``x-forwarded-proto``). + """ + ustawienie = getattr(settings, "SECURE_PROXY_SSL_HEADER", None) + if not ustawienie: + # Django bez tego ustawienia w ogóle nie ufa proxy; my też nie — + # zostaje schemat z gniazda, więc nazwa nagłówka nie ma znaczenia. + return "", "https" + meta, wartosc_https = ustawienie + nazwa = meta[len("HTTP_") :] if meta.startswith("HTTP_") else meta + return nazwa.replace("_", "-").lower(), wartosc_https + + +def schemat_zadania(naglowki: Mapping[str, str], domyslny: str = "http") -> str: + """Rozstrzygnij ``http``/``https`` dla żądania z podanymi nagłówkami. + + nginx→uvicorn jest plaintext, więc ``scope["scheme"]`` mówi ``http`` nawet + dla ruchu, który do nginksa przyszedł po HTTPS (spec §7.6). Gunicorn nie + ustawia ``forwarded_allow_ips``, więc uvicorn nagłówkom proxy NIE ufa i sam + schematu nie poprawi — musimy przeczytać nagłówek ręcznie. + """ + nazwa, wartosc_https = _naglowek_schematu() + if nazwa and naglowki.get(nazwa) == wartosc_https: + return "https" + return "https" if domyslny in ("https", "wss") else "http" diff --git a/src/mcp_server/routing.py b/src/mcp_server/routing.py index 22d46ba82..461112031 100644 --- a/src/mcp_server/routing.py +++ b/src/mcp_server/routing.py @@ -2,6 +2,8 @@ from __future__ import annotations +import json +import logging import time from bpp_mcp.auth import set_current_bearer @@ -9,14 +11,20 @@ from django_bpp.client_ip import get_client_ip from mcp_server.auth import BramkaBearera -from mcp_server.kontekst import DaneZadania, dane_zadania +from mcp_server.kontekst import DaneZadania, dane_zadania, schemat_zadania from mcp_server.start import StartMcp +from mcp_server.uczelnia import host_rozstrzyga_uczelnie +from mcp_server.zdrowie import stan_startu + +logger = logging.getLogger(__name__) #: Budżet czasu jednego wywołania narzędzia (spec D11). BUDZET_SEKUND = getattr(settings, "MCP_BUDZET_SEKUND", 25.0) SCIEZKA_PUBLICZNA = "/mcp" SCIEZKA_Z_LOGOWANIEM = "/mcp/auth" +#: Sonda stanu MCP — patrz ``mcp_server.zdrowie`` (dlaczego nie ``/health/``). +SCIEZKA_STANU = "/mcp/status" class LifespanMcp: @@ -59,6 +67,9 @@ def __init__(self, mcp_app, django_app, start: StartMcp) -> None: async def __call__(self, scope, receive, send): sciezka = scope.get("path", "") + if sciezka == SCIEZKA_STANU: + await self._stan(send) + return if sciezka not in (SCIEZKA_PUBLICZNA, SCIEZKA_Z_LOGOWANIEM): await self._django(scope, receive, send) return @@ -67,13 +78,64 @@ async def __call__(self, scope, receive, send): await self._przekieruj(send, "/mcp/") return + dane = self._dane(scope) + poczatek = time.monotonic() + # Kod odpowiedzi znamy dopiero z komunikatu wysłanego przez warstwę + # niżej — podglądamy go w drodze na zewnątrz, żeby log audytowy + # (spec §7.5) miał co zapisać. Żądania wewnętrzne nie trafiają ani do + # access-logu nginksa, ani uvicorna, więc bez tego /mcp nie zostawia + # ŻADNEGO śladu. + widziany = {} + + async def sledzacy(komunikat): + if komunikat["type"] == "http.response.start": + widziany["status"] = komunikat["status"] + await send(komunikat) + + try: + await self._obsluz(sciezka, scope, receive, sledzacy, dane) + finally: + logger.info( + "mcp %s %s host=%s bearer=%s status=%s czas=%.3fs", + scope.get("method", "?"), + sciezka, + dane.host, + # OBECNOŚĆ, nigdy wartość — token w logu byłby poświadczeniem + # leżącym w pliku, który wędruje do agregatora i backupów. + "tak" if dane.bearer else "nie", + widziany.get("status"), + time.monotonic() - poczatek, + ) + + async def _obsluz(self, sciezka, scope, receive, send, dane: DaneZadania): + """Właściwa obsługa żądania do ``/mcp`` — bez warstwy logowania.""" # PIERWSZA instrukcja i POZA jakimkolwiek cancel scope'em (spec §5.1). - await self._start.zapewnij() + try: + await self._start.zapewnij() + except Exception: + # KOLEJNOŚĆ MA ZNACZENIE: ``zapewnij()`` rzuca, gdy menedżer jest + # zatruty, więc sprawdzenie ``self._start.zywy`` poniżej samo + # z siebie nigdy by do tego stanu nie doszło — gałąź 503 byłaby + # martwym kodem, a wyjątek wyszedłby poza aplikację ASGI jako gołe + # 500 bez treści. Do Rollbara ta awaria trafia raz, ze ``StartMcp`` + # (patrz ``start._trzymaj``), a nie raz na żądanie. + logger.exception("mcp: menedżer sesji MCP niedostępny") + await self._niedostepny(send) + return if not self._start.zywy: await self._niedostepny(send) return - dane = self._dane(scope) + # Fail-closed na tożsamości uczelni (spec §7.2) — patrz + # ``mcp_server.uczelnia``: host, dla którego nie da się rozstrzygnąć + # uczelni, otwierałby bramkę API na oścież. + if not await host_rozstrzyga_uczelnie(dane.host): + logger.warning( + "mcp: odrzucony host bez jednoznacznej uczelni: %r", dane.host + ) + await self._nieznany_host(send) + return + zeton = dane_zadania.set(dane) # DWA ContextVary, nie jeden. `BppClient._auth_kwargs` czyta token # z WŁASNEGO ContextVara pakietu bpp_mcp (`bpp_mcp.auth`), nie @@ -97,6 +159,24 @@ async def __call__(self, scope, receive, send): # obsłużonego w tym samym kontekście. set_current_bearer(None) + async def _stan(self, send) -> None: + """Sonda ``/mcp/status``: bez uwierzytelnienia, bez bazy, bez Django. + + Startuje menedżera, jeśli jeszcze nie działa — pod Daphne (brak + protokołu lifespan) start jest leniwy, więc sonda, która by go nie + wyzwoliła, raportowałaby „padnięte” na zdrowym, świeżym workerze. + Po pierwszym razie wywołanie jest idempotentne i nic nie kosztuje. + """ + try: + await self._start.zapewnij() + except Exception: + # To JEST odpowiedź tej sondy, a nie sytuacja wyjątkowa — do + # Rollbara awaria poszła już ze ``StartMcp``. Logujemy z pełnym + # traceback i odpowiadamy 503 poniżej. + logger.exception("mcp/status: menedżer sesji MCP niedostępny") + tresc, kod = stan_startu(self._start) + await self._json(send, kod, tresc) + @staticmethod def _chce_html(scope) -> bool: for klucz, wartosc in scope.get("headers", []): @@ -113,17 +193,17 @@ def _dane(scope) -> DaneZadania: # wygląda podłączony, a nic nie przenosi. naglowki = {k.lower().decode(): v.decode() for k, v in scope.get("headers", [])} host = naglowki.get("host", "localhost") - # nginx→uvicorn jest plaintext; prawdziwy schemat niesie - # X-Forwarded-Proto (spec §7.6). Nagłówka NIE przekazujemy dalej — - # służy wyłącznie do zbudowania adresu bazowego. - scheme = naglowki.get("x-forwarded-proto", scope.get("scheme", "http")) + # nginx→uvicorn jest plaintext; prawdziwy schemat niesie nagłówek + # wskazany przez SECURE_PROXY_SSL_HEADER (spec §7.6). Nagłówka NIE + # przekazujemy dalej — służy wyłącznie do zbudowania adresu bazowego. + scheme = schemat_zadania(naglowki, scope.get("scheme", "http")) bearer = None surowy = naglowki.get("authorization", "") if surowy.lower().startswith("bearer "): bearer = surowy.split(" ", 1)[1].strip() return DaneZadania( host=host, - scheme="https" if scheme == "https" else "http", + scheme=scheme, ip=get_client_ip(_UdajeRequest(scope, naglowki)), bearer=bearer, deadline=time.monotonic() + BUDZET_SEKUND, @@ -140,18 +220,33 @@ async def _przekieruj(send, gdzie: str) -> None: ) await send({"type": "http.response.body", "body": b""}) + @classmethod + async def _niedostepny(cls, send) -> None: + await cls._json(send, 503, {"error": "mcp_unavailable"}) + + @classmethod + async def _nieznany_host(cls, send) -> None: + """421 Misdirected Request — ten sam kod, którym SDK odrzuca zły Host. + + Treść celowo nie zdradza, których hostów serwis obsługuje: to jest + odpowiedź dla nieuwierzytelnionego świata. + """ + await cls._json(send, 421, {"error": "unknown_host"}) + @staticmethod - async def _niedostepny(send) -> None: + async def _json(send, kod: int, tresc: dict) -> None: + cialo = json.dumps(tresc).encode() await send( { "type": "http.response.start", - "status": 503, - "headers": [(b"content-type", b"application/json")], + "status": kod, + "headers": [ + (b"content-type", b"application/json"), + (b"content-length", str(len(cialo)).encode()), + ], } ) - await send( - {"type": "http.response.body", "body": b'{"error":"mcp_unavailable"}'} - ) + await send({"type": "http.response.body", "body": cialo}) class _UdajeRequest: diff --git a/src/mcp_server/start.py b/src/mcp_server/start.py index e7e1e7661..1e469a1e0 100644 --- a/src/mcp_server/start.py +++ b/src/mcp_server/start.py @@ -26,6 +26,23 @@ from __future__ import annotations import asyncio +import logging + +import rollbar + +logger = logging.getLogger(__name__) + + +def _odbierz_wyjatek(zadanie: asyncio.Task) -> None: + """Odczytaj wyjątek zakończonego host taska, żeby pętla nie hałasowała. + + Nikt tego zadania nie awaituje (żyje tyle, co proces), więc asyncio przy + zbieraniu śmieci wypisałoby „Task exception was never retrieved”. Wyjątek + jest już zapamiętany w ``_blad`` i zgłoszony do Rollbara w ``_trzymaj`` — + tu tylko go kwitujemy, niczego nie połykając. + """ + if not zadanie.cancelled(): + zadanie.exception() class StartMcp: @@ -54,11 +71,23 @@ async def zapewnij(self) -> None: Idempotentne. Wołane z ``LifespanMcp`` (uvicorn) albo z pierwszego żądania (Daphne — nie implementuje protokołu lifespan). + + **Nie ponawiamy startu po anulowaniu**, mimo że kusi: + ``StreamableHTTPSessionManager.run()`` SDK jawnie odmawia drugiego + wejścia („can only be called once per instance”), więc próba restartu + na tej samej instancji jest z góry skazana. Zamieniałaby ciche, + kontrolowane 503 na zgłoszenie do Rollbara i traceback w logu przy + każdym żądaniu po zamknięciu pętli — czyli dokładnie ten hałas, + którego pozbywamy się w warstwie narzędzi. Odzyskanie sprawności + wymaga nowej instancji aplikacji, czyli recyklingu workera + (``--max-requests`` sprawia, że dzieje się to samo), a monitoring + dowiaduje się o tym z ``/mcp/status``. """ if self._zadanie is None: # create_task nie ma punktu przerwania między testem a przypisaniem # w tej samej pętli, więc blokada nie jest potrzebna. self._zadanie = asyncio.get_running_loop().create_task(self._trzymaj()) + self._zadanie.add_done_callback(_odbierz_wyjatek) await self._gotowe.wait() if self._blad is not None: raise RuntimeError("Menedżer sesji MCP nie wstał") from self._blad @@ -69,7 +98,27 @@ async def _trzymaj(self) -> None: async with self._aplikacja.router.lifespan_context(self._aplikacja): self._gotowe.set() await asyncio.Event().wait() + except asyncio.CancelledError: + # Anulowanie NIE jest awarią i celowo NIE trafia do ``_blad``. + # Przychodzi normalnie przy zamykaniu procesu: uvicorn anuluje + # pozostałe zadania po ``lifespan.shutdown``, a ``asyncio.run`` + # robi to samo przy zamykaniu pętli. Zapamiętane jako trwały błąd + # sypałoby Rollbara zgłoszeniem z KAŻDEGO zamknięcia workera + # i zamieniało kontrolowane 503 na wyjątek z ``zapewnij()``. + # Zwalniamy czekających, żeby nikt nie wisiał na zdarzeniu, które + # nigdy nie zostanie ustawione; ``zywy`` i tak jest już False, bo + # zadanie się zakończyło — żądania dostaną 503. + self._gotowe.set() + raise except BaseException as exc: + # JEDYNE miejsce, w którym ten wyjątek ktokolwiek zobaczy: + # CustomRollbarNotifierMiddleware łapie wyjątki Django, a ten + # leci w zadaniu tła poza jakimkolwiek żądaniem (spec §7.5). + # Raportujemy TUTAJ, a nie w RouterHttp, żeby zgłoszenie poszło + # raz na awarię, a nie raz na żądanie — inaczej martwy menedżer + # sam wyczerpałby kwotę Rollbara. + logger.exception("mcp: lifespan aplikacji MCP zakończył się błędem") + rollbar.report_exc_info() self._blad = exc self._gotowe.set() raise diff --git a/src/mcp_server/tests/test_auth.py b/src/mcp_server/tests/test_auth.py index 1d291412d..195013f0d 100644 --- a/src/mcp_server/tests/test_auth.py +++ b/src/mcp_server/tests/test_auth.py @@ -50,10 +50,55 @@ def test_publiczny_bez_naglowka_przepuszcza(): @pytest.mark.django_db(transaction=True) def test_wymagany_bez_naglowka_daje_401_z_naglowkiem(): + """Asercja na PEŁNY adres, nie na obecność podciągu ``resource_metadata=``. + + Słaba wersja tego testu przepuściła bloker: ``WWW-Authenticate`` wskazywał + PRM pod ``http://`` na produkcji (schemat brany ze ``scope["scheme"]``, + któremu uvicorn nie poprawia, bo gunicorn nie ustawia + ``forwarded_allow_ips``), a asercja na sam podciąg tego nie widziała — + mimo że zepsute discovery OAuth to jedyny powód, dla którego ten adres + istnieje. + """ app = BramkaBearera(_ok, wymagany=True) status, naglowki, _ = uruchom(lambda: wywolaj(app, zbuduj_scope("/mcp/auth"))) assert status == 401 - assert "resource_metadata=" in naglowki["www-authenticate"] + assert naglowki["www-authenticate"] == ( + 'Bearer resource_metadata="http://bpp.example.test' + '/.well-known/oauth-protected-resource"' + ) + + +@pytest.mark.django_db(transaction=True) +def test_prm_ma_https_gdy_proxy_tak_mowi(settings): + """Produkcja: nginx w osobnym kontenerze, ruch do uvicorna plaintextem. + + Schemat czytamy z nagłówka wskazanego przez ``SECURE_PROXY_SSL_HEADER``, + a nie z zaszytego ``X-Forwarded-Proto`` — ``settings/base.py`` deklaruje + ``HTTP_X_FORWARDED_PROTOCOL``, a ``settings/production.py`` + ``HTTP_X_FORWARDED_PROTO``, więc zaszycie jednej nazwy rozjeżdżałoby + warstwę MCP z Django w jednym z dwóch środowisk. + """ + settings.SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + app = BramkaBearera(_ok, wymagany=True) + scope = zbuduj_scope("/mcp/auth", naglowki={"x-forwarded-proto": "https"}) + status, naglowki, _ = uruchom(lambda: wywolaj(app, scope)) + assert status == 401 + assert naglowki["www-authenticate"] == ( + 'Bearer resource_metadata="https://bpp.example.test' + '/.well-known/oauth-protected-resource"' + ) + + +@pytest.mark.django_db(transaction=True) +def test_prm_ignoruje_naglowek_spoza_ustawienia(settings): + """Nagłówek INNY niż zadeklarowany w ``SECURE_PROXY_SSL_HEADER`` nie ma + prawa podnieść schematu — inaczej dowolny klient podnosiłby go sam, + a Django (które ufa tylko zadeklarowanemu) widziałoby co innego.""" + settings.SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTOCOL", "https") + app = BramkaBearera(_ok, wymagany=True) + scope = zbuduj_scope("/mcp/auth", naglowki={"x-forwarded-proto": "https"}) + _, naglowki, _ = uruchom(lambda: wywolaj(app, scope)) + assert 'resource_metadata="http://' in naglowki["www-authenticate"] @pytest.mark.django_db(transaction=True) diff --git a/src/mcp_server/tests/test_bramka_uczelni.py b/src/mcp_server/tests/test_bramka_uczelni.py new file mode 100644 index 000000000..c5fb849bd --- /dev/null +++ b/src/mcp_server/tests/test_bramka_uczelni.py @@ -0,0 +1,112 @@ +"""Fail-closed na tożsamości uczelni: host bez uczelni NIE dochodzi do MCP. + +Bloker, który to wymusił: ``ALLOWED_HOSTS`` na produkcji zawiera hosty +infrastrukturalne (``127.0.0.1``, ``appserver``, ``appserver:8000`` — +``settings/production.py``). Przechodziły OBIE bramki (allowlista SDK i +``get_host()`` Django), a potem ``Uczelnia.objects.get_for_request`` nie +znajdowało dla nich ``Site``. Skutek w instalacji wielouczelnianej: + +* ``BramkaApiV1.has_permission`` przy ``Uczelnia=None`` zwraca ``True`` + BEZWARUNKOWO — także przy ``api_v1_wlaczone=False``; +* ``scope_rekord_do_uczelni`` staje się no-op; +* ``UkryjStatusyKorektyMixin`` przestaje wykluczać ukryte statusy korekty. + +Czyli ``curl -H 'Host: appserver' https:///mcp`` obchodził wyłącznik +API i filtr ukrytych statusów. Testy poniżej pilnują obu stron kontraktu: +odmowy w instalacji wielouczelnianej i ZACHOWANIA działania w jednouczelnianej, +gdzie fallback na ``SITE_ID`` jest legalny. +""" + +import pytest + +from mcp_server.routing import RouterHttp +from mcp_server.start import StartMcp +from mcp_server.tests.utils import uruchom, wywolaj, zbuduj_scope + +pytestmark = pytest.mark.django_db(transaction=True) + + +class _AtrapaLifespanu: + def __init__(self): + self.router = self + + def lifespan_context(self, _app): + from contextlib import asynccontextmanager + + @asynccontextmanager + async def _ctx(): + yield + + return _ctx() + + +async def _mcp(scope, receive, send): + await send({"type": "http.response.start", "status": 200, "headers": []}) + await send({"type": "http.response.body", "body": b"MCP"}) + + +async def _django(scope, receive, send): + await send({"type": "http.response.start", "status": 200, "headers": []}) + await send({"type": "http.response.body", "body": b"DJANGO"}) + + +def _odpytaj(host, sciezka="/mcp"): + router = RouterHttp(_mcp, _django, StartMcp(_AtrapaLifespanu())) + return uruchom(lambda: wywolaj(router, zbuduj_scope(sciezka, host=host))) + + +def test_host_infrastrukturalny_odrzucony_przy_wielu_uczelniach(uczelnia1, uczelnia2): + """DOKŁADNIE scenariusz blokera: ``Host: appserver`` jest w produkcyjnym + ``ALLOWED_HOSTS``, nie ma swojego ``Site`` i przy dwóch uczelniach niczego + nie rozstrzyga. Ma dostać 421, a nie obsługę z otwartą bramką.""" + status, _, tresc = _odpytaj("appserver") + assert status == 421 + assert "MCP" not in tresc + + +def test_localhost_z_portem_tez_odrzucony_przy_wielu_uczelniach(uczelnia1, uczelnia2): + """Port w nagłówku ``Host`` nie może być furtką — rozstrzyganie ``Site`` + porównuje samą nazwę, więc i bramka musi ją odcinać tak samo.""" + status, _, _ = _odpytaj("appserver:8000") + assert status == 421 + + +def test_host_wlasnej_uczelni_przechodzi(uczelnia1, uczelnia2): + """Kontrola pozytywna: host, który MA swój ``Site``, przechodzi normalnie — + bramka nie może być „odrzuca wszystko”.""" + status, _, tresc = _odpytaj("uczelnia1.localhost") + assert status == 200 and tresc == "MCP" + + +def test_instalacja_jednouczelniana_dziala_z_dowolnym_dozwolonym_hostem(uczelnia): + """NIE WOLNO zepsuć instalacji jednouczelnianej. Tam fallback na ``SITE_ID`` + (a dalej na „jedyną uczelnię”) jest legalny i tak działa cały serwis — host + bez własnego ``Site`` musi więc dalej przechodzić.""" + status, _, tresc = _odpytaj("bpp.example.test") + assert status == 200 and tresc == "MCP" + + +def test_brak_uczelni_w_bazie_odrzucony(): + """Świeża instalacja / niedokończony kreator: nie ma czego serwować, a + ``Uczelnia=None`` to właśnie stan, w którym bramka API przepuszcza + wszystko. Fail-closed.""" + status, _, _ = _odpytaj("bpp.example.test") + assert status == 421 + + +def test_bramka_dziala_takze_na_mcp_auth(uczelnia1, uczelnia2): + """Adres z logowaniem musi mieć tę samą bramkę — inaczej zostałaby dziura + o rozmiarze jednego znaku w ścieżce.""" + status, _, _ = _odpytaj("appserver", "/mcp/auth") + assert status == 421 + + +def test_strona_dla_czlowieka_nie_jest_bramkowana(uczelnia1, uczelnia2): + """``/mcp/`` (ze slashem) idzie normalnym trybem przez Django, gdzie host + obsługuje ``ALLOWED_HOSTS`` i ``SiteResolutionMiddleware`` — nasza bramka + nie ma prawa go dotykać.""" + router = RouterHttp(_mcp, _django, StartMcp(_AtrapaLifespanu())) + status, _, tresc = uruchom( + lambda: wywolaj(router, zbuduj_scope("/mcp/", metoda="GET", host="appserver")) + ) + assert status == 200 and tresc == "DJANGO" diff --git a/src/mcp_server/tests/test_dozwolone_hosty.py b/src/mcp_server/tests/test_dozwolone_hosty.py index 5a23c944d..d680a0d0d 100644 --- a/src/mcp_server/tests/test_dozwolone_hosty.py +++ b/src/mcp_server/tests/test_dozwolone_hosty.py @@ -5,6 +5,13 @@ inna (SDK dopasowuje dokładnie albo wzorcem ``host:*``; Django rozumie ``*`` i wiodącą ``.domenę``), więc błąd tutaj otwiera albo zamyka DNS rebinding protection na całej ``/mcp``. + +**Zakres odpowiedzialności — świadomie wąski.** Ta lista NIE jest kontrolą +wielotenantową i nie ma nią być: nie da się jej policzyć z bazy, bo biegnie +przy imporcie ``django_bpp.asgi``. Tożsamość uczelni rozstrzyga per żądanie +``mcp_server.uczelnia`` (testy: ``test_bramka_uczelni.py``) i to tam jest +fail-closed. Ostatni test w tym pliku pilnuje, żeby ten podział nie został +przez pomyłkę „naprawiony” tutaj — bo naprawa tutaj byłaby pozorna. """ from mcp_server.aplikacja import _dozwolone_hosty @@ -54,3 +61,23 @@ def test_wiodaca_kropka_django_bez_wiodacej_kropki_w_wyniku(settings): hosty = _dozwolone_hosty() assert hosty == ["przyklad.test", "przyklad.test:*"] assert not any(h.startswith(".") for h in hosty) + + +def test_hosty_infrastrukturalne_przechodza_ta_warstwe(settings): + """Dokumentacja podziału odpowiedzialności, nie akceptacja dziury. + + Produkcyjne ``ALLOWED_HOSTS`` zawiera ``127.0.0.1``, ``appserver`` + i ``appserver:8000`` (``settings/production.py`` — potrzebne m.in. sondzie + Dockera). Ta funkcja je przepuszcza i tak ma zostać: ochrona przed DNS + rebindingiem to nie to samo, co rozstrzyganie uczelni. + + Żądanie z takim hostem jest odrzucane 421-ką dopiero PER ŻĄDANIE, przez + ``mcp_server.uczelnia`` — bo dopiero tam wolno zapytać bazę, czy host ma + swój ``Site``. Gdyby ktoś kiedyś zawęził tę listę „dla bezpieczeństwa”, + dostałby zawężenie liczone z konfiguracji, a nie z danych — czyli poczucie + bezpieczeństwa bez samego bezpieczeństwa. + """ + settings.ALLOWED_HOSTS = ["bpp.przyklad.test", "appserver", "127.0.0.1"] + hosty = _dozwolone_hosty() + assert "appserver" in hosty + assert "127.0.0.1" in hosty diff --git a/src/mcp_server/tests/test_e2e.py b/src/mcp_server/tests/test_e2e.py index 135c4d355..2182bf393 100644 --- a/src/mcp_server/tests/test_e2e.py +++ b/src/mcp_server/tests/test_e2e.py @@ -1,4 +1,6 @@ -"""Pełna ścieżka: POST /mcp → initialize → tools/list, bez sieci.""" +"""Pełna ścieżka: POST /mcp → initialize → tools/list → tools/call, bez sieci.""" + +import json import pytest @@ -6,43 +8,130 @@ from mcp_server.routing import RouterHttp from mcp_server.tests.utils import uruchom, wywolaj, zbuduj_scope +NAGLOWKI_JSONRPC = { + "content-type": "application/json", + # Bez tego SDK odrzuca żądanie w transport_security (klient MCP musi + # deklarować oba typy, także przy json_response=True). + "accept": "application/json, text/event-stream", +} + async def _django(scope, receive, send): await send({"type": "http.response.start", "status": 404, "headers": []}) await send({"type": "http.response.body", "body": b""}) -def _router(settings): +def _router(settings, django_app=_django): settings.ALLOWED_HOSTS = ["bpp.example.test"] mcp_app, start = build_application() - return RouterHttp(mcp_app, _django, start) + return RouterHttp(mcp_app, django_app, start) + + +async def _jsonrpc(router, cialo, host="bpp.example.test"): + """Jedno żądanie JSON-RPC do ``/mcp``. + + Korutyna, nie funkcja synchroniczna: cały dialog MUSI zmieścić się + w JEDNEJ pętli zdarzeń. ``uruchom`` zakłada świeżą pętlę per wywołanie, + a zamknięcie pętli anuluje host task ``StartMcp``, więc kolejne żądanie + w nowej pętli zastałoby menedżera sesji martwego i dostało 503 — tak jak + w produkcji po recyklingu workera. + """ + scope = zbuduj_scope("/mcp", host=host, naglowki=NAGLOWKI_JSONRPC) + return await wywolaj(router, scope, cialo) + + +def _wywolaj_jsonrpc(router, cialo, host="bpp.example.test"): + return uruchom(lambda: _jsonrpc(router, cialo, host)) + + +INITIALIZE = { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": {"name": "test", "version": "0"}, + }, +} @pytest.mark.django_db(transaction=True) -def test_initialize_zwraca_serverinfo(settings): +def test_initialize_zwraca_serverinfo(settings, uczelnia): router = _router(settings) - scope = zbuduj_scope( - "/mcp", - naglowki={ - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - ) - cialo = { - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": { - "protocolVersion": "2025-06-18", - "capabilities": {}, - "clientInfo": {"name": "test", "version": "0"}, - }, - } - status, _, tresc = uruchom(lambda: wywolaj(router, scope, cialo)) + status, _, tresc = _wywolaj_jsonrpc(router, INITIALIZE) assert status == 200 assert '"serverInfo"' in tresc and '"bpp"' in tresc +def _wynik_narzedzia(tresc: str) -> dict: + """Wyłuskaj wynik narzędzia z odpowiedzi JSON-RPC. + + SDK pakuje go dwukrotnie: JSON-RPC → ``result.content[0].text`` → JSON + zwrócony przez narzędzie. Asercja na sam surowy tekst odpowiedzi + przeszłaby też wtedy, gdyby nazwisko trafiło tam jako echo argumentu, + więc rozpakowujemy i patrzymy na DANE. + """ + odpowiedz = json.loads(tresc) + assert "error" not in odpowiedz, odpowiedz + wynik = odpowiedz["result"] + assert not wynik.get("isError"), wynik + return json.loads(wynik["content"][0]["text"]) + + +@pytest.mark.django_db(transaction=True) +def test_initialize_tools_list_tools_call_przez_pelny_stos(settings, uczelnia, autor): + """Kryterium spec §12: ``initialize`` → ``tools/list`` → ``tools/call`` + zwracające dane publiczne identyczne z ``/api/v1/``. + + To jest ścieżka, która jest SENSEM tej zmiany, a nie miała żadnej + regresji — plik sprawdzał wyłącznie ``initialize``. Wywołanie idzie przez + ``RouterHttp`` → bramkę bearera → aplikację MCP → narzędzie → klienta + w procesie → prawdziwe ``/api/v1/autor/`` w Django. Bez sieci, ale przez + KOMPLET warstw: gdyby którakolwiek z nich (kontekst żądania, mostek + bearera, ``KlientScope``, allowlista ścieżek) przestała działać, ten test + to zobaczy. + """ + autor.nazwisko = "Nazwiskotestowe" + autor.save() + + router = _router(settings) + + async def dialog(): + wyniki = [await _jsonrpc(router, INITIALIZE)] + wyniki.append( + await _jsonrpc(router, {"jsonrpc": "2.0", "id": 2, "method": "tools/list"}) + ) + wyniki.append( + await _jsonrpc( + router, + { + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "szukaj_autora", + "arguments": {"nazwisko": "Nazwiskotestowe"}, + }, + }, + ) + ) + return wyniki + + (init, lista, wywolanie) = uruchom(dialog) + + assert init[0] == 200, init + assert lista[0] == 200, lista + nazwy = {n["name"] for n in json.loads(lista[2])["result"]["tools"]} + assert "szukaj_autora" in nazwy + + status, _, tresc = wywolanie + assert status == 200, tresc + dane = _wynik_narzedzia(tresc) + assert dane["laczna_liczba"] == 1, dane + assert [a["nazwisko"] for a in dane["autorzy"]] == ["Nazwiskotestowe"] + + @pytest.mark.django_db(transaction=True) def test_zle_host_daje_421(settings): """Ochrona przed DNS-rebinding SDK działa niezależnie od ALLOWED_HOSTS.""" diff --git a/src/mcp_server/tests/test_logowanie.py b/src/mcp_server/tests/test_logowanie.py new file mode 100644 index 000000000..746926b89 --- /dev/null +++ b/src/mcp_server/tests/test_logowanie.py @@ -0,0 +1,123 @@ +"""Ślad audytowy żądań /mcp (spec §7.5) — i to, czego w nim BYĆ NIE MOŻE. + +Żądania na ``/mcp`` nie trafiają ani do access-logu nginksa (wewnętrzne +wywołania go omijają), ani uvicorna w formie użytecznej do audytu. Publiczny, +nieuwierzytelniony endpoint na produkcyjnej domenie uczelni nie zostawiał +przed tą poprawką ŻADNEGO śladu — nie było jak zbadać nadużycia ani zmierzyć +obciążenia. + +Spec wymaga wprost: „Logujemy je w KontekstMcp (ścieżka, kod, czas, obecność +bearera — **nigdy token**)”. +""" + +import logging + +import pytest + +from mcp_server.routing import RouterHttp +from mcp_server.start import StartMcp +from mcp_server.tests.utils import uruchom, wywolaj, zbuduj_scope + +pytestmark = pytest.mark.django_db(transaction=True) + +TOKEN = "SEKRETNY-TOKEN-KTORY-NIE-MOZE-TRAFIC-DO-LOGU" + + +class _AtrapaLifespanu: + def __init__(self): + self.router = self + + def lifespan_context(self, _app): + from contextlib import asynccontextmanager + + @asynccontextmanager + async def _ctx(): + yield + + return _ctx() + + +async def _mcp(scope, receive, send): + await send({"type": "http.response.start", "status": 207, "headers": []}) + await send({"type": "http.response.body", "body": b"MCP"}) + + +async def _django(scope, receive, send): + await send({"type": "http.response.start", "status": 200, "headers": []}) + await send({"type": "http.response.body", "body": b""}) + + +def _token_w_bazie(raw): + from datetime import timedelta + + from django.contrib.auth import get_user_model + from django.utils import timezone + from model_bakery import baker + from oauth2_provider.models import get_access_token_model, get_application_model + + user = baker.make(get_user_model(), is_active=True) + app = get_application_model().objects.create( + name="test-log", + client_type="public", + authorization_grant_type="authorization-code", + ) + return get_access_token_model().objects.create( + user=user, + application=app, + token=raw, + scope="read", + expires=timezone.now() + timedelta(seconds=3600), + ) + + +def _wywolaj(naglowki=None): + router = RouterHttp(_mcp, _django, StartMcp(_AtrapaLifespanu())) + return uruchom( + lambda: wywolaj(router, zbuduj_scope("/mcp", naglowki=naglowki or {})) + ) + + +def test_log_ma_sciezke_kod_czas_host_i_brak_bearera(caplog, uczelnia): + with caplog.at_level(logging.INFO, logger="mcp_server.routing"): + status, _, _ = _wywolaj() + assert status == 207 + (rekord,) = [r for r in caplog.records if r.name == "mcp_server.routing"] + wiadomosc = rekord.getMessage() + assert "/mcp" in wiadomosc + assert "status=207" in wiadomosc, "kod odpowiedzi musi być w logu" + assert "host=bpp.example.test" in wiadomosc + assert "bearer=nie" in wiadomosc + assert "czas=" in wiadomosc + + +def test_log_odnotowuje_obecnosc_bearera_ale_nie_jego_wartosc(caplog, uczelnia): + """Sedno wymogu: OBECNOŚĆ, nigdy wartość. Token w logu byłby + poświadczeniem leżącym w pliku, który wędruje do agregatora i backupów.""" + _token_w_bazie(TOKEN) + with caplog.at_level(logging.INFO, logger="mcp_server.routing"): + _wywolaj({"authorization": f"Bearer {TOKEN}"}) + + rekordy = [r for r in caplog.records if r.name == "mcp_server.routing"] + assert rekordy, "żądanie z bearerem musi zostawić ślad" + assert "bearer=tak" in rekordy[0].getMessage() + + # Cały strumień logów, nie tylko nasz rekord — token nie może wyciec + # ŻADNĄ drogą (argumenty formatowania, wyjątki, warstwy niżej). + caly_log = "\n".join(r.getMessage() for r in caplog.records) + assert TOKEN not in caly_log + assert TOKEN not in "".join(str(a) for r in caplog.records for a in (r.args or ())) + + +def test_log_powstaje_takze_gdy_zadanie_zostalo_odrzucone(caplog): + """Odrzucone żądania są tym, co audyt interesuje NAJBARDZIEJ — log leci + z ``finally``, więc powstaje niezależnie od tego, którą gałęzią wyszliśmy. + Bez uczelni w bazie bramka odrzuca 421-ką.""" + with caplog.at_level(logging.INFO, logger="mcp_server.routing"): + status, _, _ = _wywolaj() + assert status == 421 + (rekord,) = [ + r + for r in caplog.records + if r.name == "mcp_server.routing" and r.levelno == logging.INFO + ] + assert "status=421" in rekord.getMessage() diff --git a/src/mcp_server/tests/test_rollbar.py b/src/mcp_server/tests/test_rollbar.py index 861a94527..feb2a90d1 100644 --- a/src/mcp_server/tests/test_rollbar.py +++ b/src/mcp_server/tests/test_rollbar.py @@ -20,7 +20,8 @@ import bpp_mcp.tools as bpp_tools import pytest -from mcp.server.mcpserver.exceptions import UnexpectedToolError +from bpp_mcp.client import BppError +from mcp.server.mcpserver.exceptions import ToolError, UnexpectedToolError from mcp_server import aplikacja from mcp_server.aplikacja import build_application @@ -31,6 +32,34 @@ async def _wybuchaj(*_a, **_kw): raise RuntimeError("awaria-testowa-narzedzia") +def _rzucajacy_bpperror(status=None): + async def narzedzie(*_a, **_kw): + raise BppError("nie znaleziono encji", status_code=status) + + return narzedzie + + +def _wywolaj_djangoql_schema(monkeypatch, funkcja, oczekiwany_wyjatek): + """Zbuduj aplikację z podmienionym ``djangoql_schema`` i zawołaj narzędzie. + + ``djangoql_schema`` nie potrzebuje ani ``ctx``, ani klienta HTTP — to + najlżejsze narzędzie do wywołania bez stawiania pełnego żądania. + """ + mock_rollbar = Mock() + monkeypatch.setattr(aplikacja, "rollbar", mock_rollbar) + monkeypatch.setattr(bpp_tools, "djangoql_schema", funkcja) + + mcp_app, _ = build_application() + serwer = mcp_app.state.serwer_mcp + + async def scenariusz(): + with pytest.raises(oczekiwany_wyjatek): + await serwer.call_tool("djangoql_schema", {"model": "rekord"}) + + uruchom(scenariusz) + return mock_rollbar + + def test_wyjatek_narzedzia_trafia_do_rollbara(monkeypatch): """Wywołanie narzędzia, które rzuca wyjątek, musi zawołać ``rollbar.report_exc_info`` — bez tego awarie narzędzi są niewidoczne @@ -69,6 +98,41 @@ async def scenariusz(): mock_rollbar.report_exc_info.assert_not_called() +def test_bpperror_nie_trafia_do_rollbara(monkeypatch): + """``BppError`` to normalny kanał komunikatów do użytkownika, nie awaria. + + Zmierzone przed poprawką: 4 zgłoszenia na 4 wywołania (401 anonima przy + DjangoQL, dwa 404 nieistniejących encji, walidacja argumentu). Na + publicznym, nieuwierzytelnionym endpoincie znaczyło to, że dowolna osoba + z internetu wyczerpuje kwotę Rollbara jednym ``curl``-em w pętli i topi + realne alerty. + """ + mock_rollbar = _wywolaj_djangoql_schema( + monkeypatch, _rzucajacy_bpperror(status=404), ToolError + ) + mock_rollbar.report_exc_info.assert_not_called() + + +def test_bpperror_bez_statusu_nie_trafia_do_rollbara(monkeypatch): + """Błąd domenowy bez statusu HTTP (walidacja argumentu, przekroczony + budżet czasu) też jest komunikatem, nie awarią.""" + mock_rollbar = _wywolaj_djangoql_schema( + monkeypatch, _rzucajacy_bpperror(status=None), ToolError + ) + mock_rollbar.report_exc_info.assert_not_called() + + +def test_bpperror_5xx_jednak_trafia_do_rollbara(monkeypatch): + """Granica wyjątku od wyjątku: status 5xx pochodzi z odpowiedzi NASZEJ + aplikacji Django na żądanie wewnętrzne, więc mówi o awarii po naszej + stronie. Pytający nie ma jak jej wymusić z ``/api/v1/``, więc ta gałąź + nie daje się użyć do zalania Rollbara.""" + mock_rollbar = _wywolaj_djangoql_schema( + monkeypatch, _rzucajacy_bpperror(status=503), ToolError + ) + mock_rollbar.report_exc_info.assert_called_once() + + def test_context_i_schemat_przetrwaly_wrapper(): """Regresja na ryzyko opisane w docstringu ``_z_raportowaniem``: gdyby ``functools.wraps`` nie wystarczał do zachowania sygnatury/adnotacji diff --git a/src/mcp_server/tests/test_routing.py b/src/mcp_server/tests/test_routing.py index 0536b84cc..f7fe61b2c 100644 --- a/src/mcp_server/tests/test_routing.py +++ b/src/mcp_server/tests/test_routing.py @@ -73,13 +73,18 @@ def _router(): return RouterHttp(_echo_sciezki, _django, StartMcp(_AtrapaLifespanu())) -def test_mcp_trafia_do_aplikacji_mcp(): +@pytest.mark.django_db(transaction=True) +def test_mcp_trafia_do_aplikacji_mcp(uczelnia): + """``uczelnia`` NIE jest ozdobnikiem: ``RouterHttp`` odrzuca 421-ką host, + dla którego nie da się rozstrzygnąć uczelni (spec §7.2, patrz + ``mcp_server.uczelnia``). Bez żadnej uczelni w bazie żądanie nigdy nie + dotarłoby do aplikacji MCP.""" status, _, tresc = uruchom(lambda: wywolaj(_router(), zbuduj_scope("/mcp"))) assert status == 200 and tresc == "MCP:/mcp" @pytest.mark.django_db(transaction=True) -def test_mcp_auth_ma_przepisana_sciezke_na_mcp(): +def test_mcp_auth_ma_przepisana_sciezke_na_mcp(uczelnia): """REGRESJA BL-1: bez przepisania SDK oddałoby 404. ``/mcp/auth`` bez ważnego tokenu to zawsze 401 (spec §5.4, D10) — bez @@ -115,7 +120,7 @@ def test_get_mcp_z_przegladarki_przekierowuje_na_slash(): @pytest.mark.django_db(transaction=True) -def test_bearer_trafia_do_contextvara_pakietu(): +def test_bearer_trafia_do_contextvara_pakietu(uczelnia): """`BppClient` czyta token z WŁASNEGO ContextVara pakietu bpp_mcp, nie z naszego. Bez tego mostka zalogowany użytkownik po cichu leciałby anonimowo, a warstwa OAuth byłaby dekoracją. @@ -141,7 +146,7 @@ async def _podglada(scope, receive, send): @pytest.mark.django_db(transaction=True) -def test_bearer_nie_wycieka_do_nastepnego_zadania(): +def test_bearer_nie_wycieka_do_nastepnego_zadania(uczelnia): """Po zakończeniu żądania ContextVar pakietu musi być wyczyszczony.""" from bpp_mcp.auth import current_bearer @@ -163,6 +168,43 @@ async def scenariusz(): assert widziany == ["T1", None] +@pytest.mark.django_db(transaction=True) +def test_schemat_zadania_z_naglowka_wskazanego_przez_ustawienie(settings, uczelnia): + """``DaneZadania.scheme`` decyduje o ``base_url`` klienta w procesie, więc + zły schemat to albo podwojone żądanie (``SECURE_SSL_REDIRECT``), albo + adresy z ``http://`` w danych zwracanych użytkownikowi. + + Nazwa nagłówka MUSI pochodzić z ``SECURE_PROXY_SSL_HEADER`` — to samo + źródło, z którego czyta ``auth.BramkaBearera``. Zaszycie + ``X-Forwarded-Proto`` rozjechałoby warstwę MCP z Django w środowisku, + w którym ustawienie mówi ``HTTP_X_FORWARDED_PROTOCOL`` (``base.py``). + """ + settings.SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTOCOL", "https") + widziane = [] + + async def _podglada(scope, receive, send): + from mcp_server.kontekst import biezace + + widziane.append(biezace().scheme) + await send({"type": "http.response.start", "status": 200, "headers": []}) + await send({"type": "http.response.body", "body": b""}) + + router = RouterHttp(_podglada, _django, StartMcp(_AtrapaLifespanu())) + + async def scenariusz(): + await wywolaj( + router, + zbuduj_scope("/mcp", naglowki={"x-forwarded-protocol": "https"}), + ) + # Nagłówek SPOZA ustawienia nie ma prawa podnieść schematu. + await wywolaj( + router, zbuduj_scope("/mcp", naglowki={"x-forwarded-proto": "https"}) + ) + + uruchom(scenariusz) + assert widziane == ["https", "http"] + + def test_lifespan_startup_complete(): start = StartMcp(_AtrapaLifespanu()) app = LifespanMcp(start) diff --git a/src/mcp_server/tests/test_start.py b/src/mcp_server/tests/test_start.py index 552f1ca8d..553c89113 100644 --- a/src/mcp_server/tests/test_start.py +++ b/src/mcp_server/tests/test_start.py @@ -103,6 +103,75 @@ async def udaje_zadanie(): assert uruchom(scenariusz) is True +def test_anulowanie_nie_jest_zapamietane_jako_trwaly_blad(): + """``CancelledError`` NIE może latchować stanu. + + ``except BaseException`` łapało też anulowanie, więc JEDNO anulowanie + z dowolnego powodu (zamykanie procesu, ubita pętla, timeout serwera) + zapisywało się w ``_blad``. Skutki były dwa i oba złe: ``zapewnij()`` + rzucało wyjątkiem zamiast pozwolić oddać kontrolowane 503, a przy KAŻDYM + zamknięciu workera szło zgłoszenie do Rollbara — z rutynowego zdarzenia. + + Po poprawce anulowanie zwalnia czekających, nie zostaje zapamiętane + i nie jest raportowane. Endpoint i tak jest wtedy martwy + (``zywy is False`` → 503), bo ``session_manager.run()`` SDK wchodzi się + raz na instancję — świadomie NIE próbujemy restartu (patrz ``zapewnij``). + """ + from unittest.mock import Mock + + from mcp_server import start as modul_start + + mock_rollbar = Mock() + app = _AplikacjaZLifespanem() + start = StartMcp(app) + + async def scenariusz(): + await start.zapewnij() + start._zadanie.cancel() + # Dwie tury pętli: jedna na dostarczenie anulowania, druga na + # domknięcie zadania (``_trzymaj`` ma jeszcze except do wykonania). + await asyncio.sleep(0) + await asyncio.sleep(0) + await start.zapewnij() # NIE może rzucić — anulowanie to nie awaria + return start._blad, start.zywy + + with pytest.MonkeyPatch.context() as mp: + mp.setattr(modul_start, "rollbar", mock_rollbar) + blad, zywy = uruchom(scenariusz) + + assert blad is None, "anulowanie zostało zapamiętane jako trwały błąd" + assert zywy is False, "po anulowaniu żądania mają dostawać 503" + mock_rollbar.report_exc_info.assert_not_called() + + +def test_blad_startu_jest_raportowany_do_rollbara(monkeypatch): + """Wyjątek host taska nie ma żadnej innej drogi do monitoringu: + ``CustomRollbarNotifierMiddleware`` łapie wyjątki Django, a ten leci + w zadaniu tła poza jakimkolwiek żądaniem (spec §7.5). Zgłoszenie idzie + stąd, a nie z ``RouterHttp``, żeby przypadło RAZ na awarię, a nie raz na + żądanie — inaczej martwy menedżer sam wyczerpałby kwotę Rollbara.""" + from unittest.mock import Mock + + from mcp_server import start as modul_start + + mock_rollbar = Mock() + monkeypatch.setattr(modul_start, "rollbar", mock_rollbar) + + app = _AplikacjaZLifespanem(blad=RuntimeError("brak Redisa")) + start = StartMcp(app) + + async def scenariusz(): + with pytest.raises(RuntimeError): + await start.zapewnij() + # drugie i trzecie żądanie NIE mogą dołożyć kolejnych zgłoszeń + for _ in range(2): + with pytest.raises(RuntimeError): + await start.zapewnij() + + uruchom(scenariusz) + mock_rollbar.report_exc_info.assert_called_once() + + def test_wiele_rownoleglych_zapewnij_wchodzi_raz(): """Brak punktu przerwania między ``if self._zadanie is None`` a ``create_task`` (komentarz w ``start.py``) ma znaczenie tylko wtedy, gdy diff --git a/src/mcp_server/tests/test_zdrowie.py b/src/mcp_server/tests/test_zdrowie.py index 5606759d1..358592ebc 100644 --- a/src/mcp_server/tests/test_zdrowie.py +++ b/src/mcp_server/tests/test_zdrowie.py @@ -1,42 +1,153 @@ -"""``stan_mcp()`` odróżnia „proces działa" od „endpoint MCP działa" (spec §10.3). +"""Stan endpointu MCP: sonda ``/mcp/status`` i gałąź 503 na ``/mcp``. -Testy podstawiają FAŁSZYWY moduł pod ``django_bpp.asgi`` w ``sys.modules`` — -import prawdziwego modułu wykonuje ``build_application()`` przy imporcie -i ta jedna instancja żyje tyle, co proces (patrz komentarz w -``test_asgi_montaz.py``); podstawienie unika triggerowania tego kosztu/efektu -w każdym teście tego pliku. +Kryterium spec §12 („Healthcheck rozróżnia stan procesu od stanu endpointu +MCP”) było wcześniej niespełnione po OBU stronach: ``stan_mcp()`` nie było +wołane z żadnego miejsca produkcyjnego, a gałąź 503 w ``RouterHttp`` była +nieosiągalna, bo ``zapewnij()`` rzucało, ZANIM wykonanie doszło do sprawdzenia +``self._start.zywy``. + +Te testy chodzą po PRAWDZIWYM ``StartMcp`` nad aplikacją z PRAWDZIWĄ +``anyio.create_task_group()`` i zatruwają ją tak, jak robi to awaria produkcyjna +(wyjątek w zadaniu-dziecku anuluje scope grupy). Poprzednia wersja tego pliku +podstawiała atrapę z dwoma atrybutami — przeszłaby przy dowolnej implementacji, +w tym przy takiej, w której sonda nie istnieje. """ -import sys -import types +import asyncio +import json +from contextlib import asynccontextmanager + +import anyio + +from mcp_server.routing import RouterHttp +from mcp_server.start import StartMcp +from mcp_server.tests.utils import uruchom, wywolaj, zbuduj_scope + + +class _AplikacjaZGrupaZadan: + """Namiastka aplikacji Starlette z prawdziwą grupą zadań anyio. + + ``session_manager.run()`` z SDK MCP zakłada dokładnie taką grupę; awaria + produkcyjna, o którą chodzi (§10.3), to wyjątek jej zadania-dziecka. + Atrapa musi mieć grupę, bo bez niej nie da się odtworzyć zatrucia. + """ + + def __init__(self) -> None: + self.router = self + self._grupa = None + + @asynccontextmanager + async def lifespan_context(self, _app): + async with anyio.create_task_group() as grupa: + self._grupa = grupa + yield + + def zatruj(self) -> None: + """Wpuść do grupy dziecko, które rzuca — jak awaria w menedżerze sesji.""" + + async def dziecko(): + raise RuntimeError("zatrucie grupy zadań menedżera sesji") + + self._grupa.start_soon(dziecko) + + +async def _django(scope, receive, send): + await send({"type": "http.response.start", "status": 200, "headers": []}) + await send( + {"type": "http.response.body", "body": b"DJANGO:" + scope["path"].encode()} + ) + + +async def _mcp(scope, receive, send): + await send({"type": "http.response.start", "status": 200, "headers": []}) + await send({"type": "http.response.body", "body": b"MCP"}) + + +def _router(app): + return RouterHttp(_mcp, _django, StartMcp(app)) + + +def test_status_startuje_menedzera_i_oddaje_200(): + """Świeży worker pod Daphne nie przeszedł protokołu lifespan, więc sonda + MUSI wyzwolić leniwy start — inaczej raportowałaby „padnięte” na w pełni + zdrowym procesie. + + Brak ``@pytest.mark.django_db`` jest tu ASERCJĄ, nie zaniedbaniem: sonda + ma nie dotykać bazy (wymóg z briefu), a pytest-django wywali każdy dostęp + do ORM w teście bez tego markera. + """ + app = _AplikacjaZGrupaZadan() + router = _router(app) + status, naglowki, tresc = uruchom( + lambda: wywolaj(router, zbuduj_scope("/mcp/status", metoda="GET")) + ) + assert status == 200 + assert naglowki["content-type"] == "application/json" + assert json.loads(tresc) == {"status": "ok", "wystartowany": True, "zywy": True} + -from mcp_server.zdrowie import stan_mcp +def test_status_po_zatruciu_oddaje_503_i_rozroznia_stan(): + """Proces żyje (odpowiada!), a endpoint MCP nie — i sonda to rozróżnia. + ``wystartowany`` zostaje w treści właśnie po to, żeby odróżnić „jeszcze nie + wstał” od „wstał i padł”: to drugie znaczy, że czekanie nic nie da i trzeba + zrecyklingować workera. + """ + app = _AplikacjaZGrupaZadan() + router = _router(app) -class _FalszywyStart: - def __init__(self, wystartowany: bool, zywy: bool) -> None: - self.wystartowany = wystartowany - self.zywy = zywy + async def scenariusz(): + przed = await wywolaj(router, zbuduj_scope("/mcp/status", metoda="GET")) + app.zatruj() + await asyncio.sleep(0.05) # niech dziecko zdąży rzucić i anulować grupę + po = await wywolaj(router, zbuduj_scope("/mcp/status", metoda="GET")) + return przed, po + (status_przed, _, _), (status_po, _, tresc_po) = uruchom(scenariusz) + assert status_przed == 200 + assert status_po == 503 + assert json.loads(tresc_po) == { + "status": "error", + "wystartowany": False, + "zywy": False, + } -def _podstaw_asgi(monkeypatch, start) -> None: - falszywy_modul = types.ModuleType("django_bpp.asgi") - falszywy_modul._mcp_start = start - monkeypatch.setitem(sys.modules, "django_bpp.asgi", falszywy_modul) +def test_mcp_po_zatruciu_oddaje_kontrolowane_503(): + """REGRESJA: gałąź 503 ``RouterHttp`` była martwym kodem. -def test_wystartowany_i_zywy(monkeypatch): - _podstaw_asgi(monkeypatch, _FalszywyStart(wystartowany=True, zywy=True)) - assert stan_mcp() == {"wystartowany": True, "zywy": True} + ``_trzymaj`` zapisuje wyjątek dziecka w ``_blad``, więc ``zapewnij()`` + rzuca RuntimeError ZANIM wykonanie dojdzie do ``if not self._start.zywy``. + Wyjątek wychodził wtedy poza aplikację ASGI — serwer oddawał gołe 500 bez + treści i bez śladu w monitoringu. Test mutacyjny: usunięcie ``try/except`` + wokół ``zapewnij()`` w ``_obsluz`` wywala ten test wyjątkiem, nie asercją. + """ + app = _AplikacjaZGrupaZadan() + router = _router(app) + async def scenariusz(): + # Zdrowy start przez sondę, nie przez /mcp: żądanie do /mcp przeszłoby + # przez bramkę uczelni (baza), a ten test ma sprawdzać wyłącznie + # kolejność sprawdzeń przy martwym menedżerze — 503 zapada ZANIM + # bramka uczelni w ogóle zostanie zawołana. + await wywolaj(router, zbuduj_scope("/mcp/status", metoda="GET")) + app.zatruj() + await asyncio.sleep(0.05) + return await wywolaj(router, zbuduj_scope("/mcp")) -def test_zatruta_grupa_zadan_wystartowany_ale_nie_zywy(monkeypatch): - """Grupa zadań zdechła po zatruciu wyjątkiem dziecka — proces wciąż - żyje, ale ``/mcp`` już nie odpowie, aż do recyklingu workera.""" - _podstaw_asgi(monkeypatch, _FalszywyStart(wystartowany=True, zywy=False)) - assert stan_mcp() == {"wystartowany": True, "zywy": False} + status, naglowki, tresc = uruchom(scenariusz) + assert status == 503 + assert json.loads(tresc) == {"error": "mcp_unavailable"} + assert naglowki["content-type"] == "application/json" -def test_jeszcze_nie_wystartowany(monkeypatch): - _podstaw_asgi(monkeypatch, _FalszywyStart(wystartowany=False, zywy=False)) - assert stan_mcp() == {"wystartowany": False, "zywy": False} +def test_status_nie_idzie_do_django(): + """``/mcp/status`` obsługuje warstwa ASGI — nie Django, więc odpowiada + także wtedy, gdy stos middleware (kreator, blokada odliczania) po drodze + przekierowuje albo blokuje.""" + app = _AplikacjaZGrupaZadan() + status, _, tresc = uruchom( + lambda: wywolaj(_router(app), zbuduj_scope("/mcp/status", metoda="GET")) + ) + assert status == 200 + assert "DJANGO" not in tresc diff --git a/src/mcp_server/uczelnia.py b/src/mcp_server/uczelnia.py new file mode 100644 index 000000000..18f6eab09 --- /dev/null +++ b/src/mcp_server/uczelnia.py @@ -0,0 +1,92 @@ +"""Fail-closed rozstrzyganie uczelni dla nagłówka ``Host`` żądania MCP. + +Na ścieżce ``/mcp`` **Host jest kontrolą dostępu** (spec §7.2): to on decyduje, +którą ``Uczelnia`` zobaczy ``BramkaApiV1``, ``scope_rekord_do_uczelni`` +i ``UkryjStatusyKorektyMixin`` w żądaniu wewnętrznym. A wszystkie trzy są +zbudowane tak, że **brak uczelni znaczy „nie ograniczaj”**: + +* ``BramkaApiV1.has_permission`` przy ``uczelnia is None`` zwraca ``True`` + BEZWARUNKOWO — także gdy administrator ustawił ``api_v1_wlaczone=False`` + albo ``api_v1_tylko_zalogowani=True`` (``api_v1/permissions.py``); +* ``scope_rekord_do_uczelni`` staje się no-op (``bpp/util/uczelnia_scope.py``); +* ``UkryjStatusyKorektyMixin`` przestaje wykluczać ukryte statusy korekty + (``api_v1/viewsets/common.py``). + +To jest bezpieczne dla zwykłego ruchu HTTP, bo tam nagłówek ``Host`` przechodzi +przez ``ALLOWED_HOSTS``, a ``ALLOWED_HOSTS`` na produkcji zawiera hosty +INFRASTRUKTURALNE (``127.0.0.1``, ``appserver``, ``appserver:8000`` — +``settings/production.py``), które nie mają swojego ``Site``. Dla nich +``Uczelnia.objects.get_for_request`` schodzi na fallback ``SITE_ID`` albo +zwraca ``None``: w instalacji wielouczelnianej ``curl -H 'Host: appserver'`` +obchodziłby wyłącznik API i filtr ukrytych statusów. + +Dlatego na tej jednej ścieżce host, który **nie identyfikuje jednoznacznie +uczelni**, jest odrzucany, zamiast obsłużony z otwartą bramką. + +Dlaczego sprawdzenie jest PER ŻĄDANIE, a nie w allowliście transportowej +(``aplikacja._dozwolone_hosty``): tamta jest liczona przy budowie aplikacji, +czyli przy imporcie ``django_bpp.asgi``, a import modułu ASGI nie może zależeć +od dostępnej bazy. Powiązanie host→``Site``→``Uczelnia`` to DANE, nie +konfiguracja — administrator dokłada i zabiera je bez restartu procesu, więc +tylko odczyt per żądanie mówi prawdę. Allowlista transportowa zostaje tym, czym +jest w SDK: ochroną przed DNS rebindingiem, a nie kontrolą wielotenantową. +""" + +from __future__ import annotations + +from asgiref.sync import sync_to_async +from django.db import close_old_connections + + +class _ZadanieZHostem: + """Namiastka ``HttpRequest`` — ``get_for_request`` czyta tylko ``get_host``.""" + + def __init__(self, host: str) -> None: + self._host = host + + def get_host(self) -> str: + return self._host + + +def _rozstrzygalny(host: str) -> bool: + """Czy ``host`` jednoznacznie wskazuje jedną ``Uczelnia``? + + Dwie ścieżki, obie zamierzone: + + 1. **Host ma swój ``Site``** — normalna sytuacja instalacji + wielouczelnianej. Wtedy pytamy ``get_for_request`` dokładnie tak, jak + zrobi to później ``BramkaApiV1``, i wymagamy niepustego wyniku. ``Site`` + bez podpiętej uczelni w instalacji z wieloma uczelniami da ``None`` → + odmowa. + 2. **Host nie ma ``Site``** — dopuszczalne WYŁĄCZNIE w instalacji + jednouczelnianej, gdzie fallback na ``SITE_ID`` (a dalej na „jedyną + uczelnię”) jest legalny i tak działa cały serwis. Warunkiem jest, żeby + uczelnia w bazie była dokładnie jedna: przy zerze nie ma czego serwować + (świeża instalacja, niedokończony kreator), a przy dwóch i więcej host + niczego nie rozstrzyga i wybór byłby zgadywaniem. + + ``close_old_connections`` jak w ``mcp_server.auth``: biegniemy poza cyklem + żądania Django, więc sygnały ``request_started/finished`` nie sprzątają + połączeń za nas. + """ + from django.contrib.sites.models import Site + + from bpp.models import Uczelnia + + hostname = host.split(":")[0] + close_old_connections() + try: + if ( + not Site.objects.filter(domain=hostname).exists() + and Uczelnia.objects.count() != 1 + ): + return False + return Uczelnia.objects.get_for_request(_ZadanieZHostem(host)) is not None + finally: + close_old_connections() + + +#: Wersja asynchroniczna — warstwa ASGI nie ma pętli, w której wolno jej +#: blokować na ORM. ``thread_sensitive=False`` jak przy weryfikacji tokenu: +#: to czysty odczyt, bez współdzielonego stanu transakcyjnego z wołającym. +host_rozstrzyga_uczelnie = sync_to_async(_rozstrzygalny, thread_sensitive=False) diff --git a/src/mcp_server/zdrowie.py b/src/mcp_server/zdrowie.py index bce80e57d..8b02c0c4f 100644 --- a/src/mcp_server/zdrowie.py +++ b/src/mcp_server/zdrowie.py @@ -1,25 +1,39 @@ -"""Stan endpointu MCP dla monitoringu. +"""Stan endpointu MCP dla monitoringu (spec §10.3). Grupa zadań menedżera sesji MCP (``StartMcp``) może zostać zatruta wyjątkiem dziecka — proces wtedy żyje, ale każde ``/mcp`` zwraca błąd aż do recyklingu -workera. Healthcheck musi więc odróżniać „proces działa" od „endpoint MCP -działa" (spec §10.3): drugie jest ostrzejszym warunkiem od pierwszego. +workera. Monitoring musi więc odróżniać „proces działa” od „endpoint MCP +działa”: drugie jest ostrzejszym warunkiem od pierwszego. + +**Dlaczego to NIE jest wpięte w ``/health/``.** Tamten endpoint jest sondą +Dockera (``docker/appserver/*``): 503 z niego restartuje cały appserver. +Awaria izolowana do MCP zabijałaby wtedy serwis, który poza ``/mcp`` działa +bez zarzutu — lekarstwo gorsze od choroby. Stąd osobny adres ``/mcp/status``, +obsługiwany przez ``RouterHttp`` (a nie przez Django), dzięki czemu odpowiada +bez uwierzytelnienia, bez bazy i bez przechodzenia przez cały stos middleware. """ from __future__ import annotations -def stan_mcp() -> dict: - """Zwróć stan startu serwera MCP z ``StartMcp`` wystawionego przez ASGI. +def stan_startu(start) -> tuple[dict, int]: + """Zwróć ``(treść JSON, kod HTTP)`` opisujące stan menedżera sesji MCP. - Import wewnątrz funkcji (nie na szczycie modułu): ``django_bpp.asgi`` - buduje aplikację MCP przy imporcie (``build_application()``), więc - importowanie go na szczycie tego modułu ściągnęłoby ten koszt do każdego - miejsca, które tylko chce zaimportować ``stan_mcp`` (np. testy). - """ - from django_bpp.asgi import _mcp_start + Czysta funkcja nad ``StartMcp`` — bierze obiekt, którym dysponuje warstwa + ASGI. Poprzednia wersja sięgała po ``django_bpp.asgi._mcp_start`` sama + i nie była wołana z ŻADNEGO miejsca produkcyjnego; szew przez argument + pozwala testom podstawić prawdziwy, zatruty ``StartMcp``, zamiast atrapy + z dwoma atrybutami, która przeszłaby przy dowolnej implementacji. - return { - "wystartowany": _mcp_start.wystartowany, - "zywy": _mcp_start.zywy, + ``zywy`` (a nie ``wystartowany``) rozstrzyga o kodzie: to ono odpowiada + na pytanie „czy POST /mcp da teraz odpowiedź”. ``wystartowany`` zostaje + w treści, bo odróżnia „jeszcze nie wstał” od „wstał i padł” — czyli mówi, + czy warto czekać, czy trzeba restartować workera. + """ + zywy = start.zywy + tresc = { + "status": "ok" if zywy else "error", + "wystartowany": start.wystartowany, + "zywy": zywy, } + return tresc, (200 if zywy else 503) From 50c10ede3bda3c341f4a25625dc3d923115b954e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sun, 6 Sep 2026 20:57:45 +0200 Subject: [PATCH 27/29] docs(mcp): spec i newsfragment po fali naprawczej MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU --- ...6-09-05-mcp-hostowany-w-serwisie-design.md | 52 ++++++++++++++++--- .../newsfragments/mcp-hostowany.feature.rst | 3 +- 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/docs/superpowers/specs/2026-09-05-mcp-hostowany-w-serwisie-design.md b/docs/superpowers/specs/2026-09-05-mcp-hostowany-w-serwisie-design.md index 29279091b..f4408e97e 100644 --- a/docs/superpowers/specs/2026-09-05-mcp-hostowany-w-serwisie-design.md +++ b/docs/superpowers/specs/2026-09-05-mcp-hostowany-w-serwisie-design.md @@ -593,6 +593,27 @@ administratora staje się dostępne przez `/mcp` — a `ukryte_statusy=None` przestaje filtrować rekordy ukrytych statusów. W instalacji wielouczelnianej to wyciek między uczelniami. +**Propagacja to za mało — potrzebna jest bramka fail-closed.** `ALLOWED_HOSTS` +na produkcji zawiera hosty infrastrukturalne (`127.0.0.1`, `appserver`, +`appserver:8000` — `production.py:154-159`), które przechodzą **obie** bramki +(allowlista transportowa SDK i `get_host()` Django), a nie mają swojego `Site`. +Propagacja `Host` przenosi je wtedy wiernie — prosto w `Uczelnia=None`, czyli +w otwartą bramkę API. `curl -H 'Host: appserver' https:///mcp` +obchodziłby wyłącznik API i filtr ukrytych statusów. + +Dlatego `mcp_server.uczelnia` sprawdza **per żądanie**, czy host jednoznacznie +wskazuje uczelnię, i odrzuca **421 Misdirected Request**, gdy nie wskazuje: + +- host ma swój `Site` → wymagamy niepustego `get_for_request` (to samo + rozstrzygnięcie, którego użyje potem `BramkaApiV1`); +- host nie ma `Site` → dopuszczalne **wyłącznie** w instalacji + jednouczelnianej, gdzie fallback na `SITE_ID` jest legalny; przy zerze + i przy dwóch-i-więcej uczelniach → odmowa. + +Sprawdzenie **musi** być per żądanie: allowlista transportowa (§6.1) liczy się +przy imporcie `django_bpp.asgi`, a import modułu ASGI nie może zależeć od bazy; +powiązanie host→`Site`→`Uczelnia` to zaś dane, nie konfiguracja. + ### 7.3 Throttling `ASGITransport` ustawia `client=("127.0.0.1", 123)`, więc bez interwencji @@ -734,12 +755,21 @@ bo na niej stoi D4. Poza zakresem tego specu (§13). z D11 działa **wewnątrz** aplikacji; strefa nginx chroni przed zalewem samych żądań MCP. Do zmierzenia, ale nie jest to już „poza zakresem" (§7.3). -3. **Healthcheck `/mcp`.** Grupa zadań menedżera może zostać „zatruta" - (wyjątek dziecka anuluje scope grupy) — proces wtedy żyje, ale **każde - `/mcp` zwraca błąd aż do recyklingu `max_requests`**. `StartMcp` wystawia - stan gotowości i licznik żywych zadań; `RouterHttp` przy martwym zadaniu - oddaje 503 zamiast 500, a monitoring ma po czym poznać, że endpoint padł - mimo zdrowego procesu (§5.1, §5.2). +3. **Healthcheck `/mcp` — osobny adres `GET /mcp/status`.** Grupa zadań + menedżera może zostać „zatruta" (wyjątek dziecka anuluje scope grupy) — + proces wtedy żyje, ale **każde `/mcp` zwraca błąd aż do recyklingu + `max_requests`**. `RouterHttp` przy martwym menedżerze oddaje kontrolowane + 503 (kolejność sprawdzeń ma znaczenie: `zapewnij()` rzuca, więc jego wyjątek + musi być złapany, inaczej gałąź 503 jest martwym kodem), a monitoring pyta + `/mcp/status`: `200 {"status":"ok"}` albo `503 {"status":"error"}` plus + `wystartowany`/`zywy` w treści (§5.1, §5.2). + + **Nie wpinamy tego w `/health/`**: tamten adres jest sondą Dockera, więc 503 + z powodu awarii izolowanej do MCP restartowałby cały appserver. `/mcp/status` + obsługuje warstwa ASGI — bez uwierzytelnienia, bez bazy i bez middleware — + i sam wyzwala leniwy start, żeby pod Daphne nie raportować „padnięte" na + świeżym, zdrowym workerze. Do monitoringu zewnętrznego wystarczy zwykły + `GET`; nginx nie wymaga zmian (adres wpada pod `location /`). Bez zmian: `stateless_http` czyni recykling `--max-requests` nieszkodliwym, `json_response` znosi problem `proxy_read_timeout`. @@ -824,7 +854,15 @@ zobaczy override'u z fixture'a `settings` (§6.1); (ii) - `/mcp/auth` dociera do aplikacji MCP (przepisanie ścieżki, BL-1). - Menedżer sesji startuje w osobnym zadaniu i przeżywa żądanie, w którym go wywołano — także gdy w tym żądaniu był aktywny `fail_after` (BL-3). -- Healthcheck rozróżnia stan procesu od stanu endpointu MCP (§10.3). +- Healthcheck rozróżnia stan procesu od stanu endpointu MCP (§10.3): + `GET /mcp/status` → 200/503, a `/mcp` przy martwym menedżerze → **503**, + nie wyciekający wyjątek. +- Host, dla którego nie da się jednoznacznie rozstrzygnąć uczelni (np. + `Host: appserver`), dostaje **421** — nie obsługę z otwartą bramką API (§7.2). +- `BppError` (normalny komunikat do użytkownika) **nie** trafia do Rollbara; + nieoczekiwany wyjątek narzędzia i awaria startu menedżera — **tak** (§7.5). +- Każde żądanie `/mcp` zostawia wpis w logu: ścieżka, kod, czas, host, + **obecność** bearera — nigdy jego wartość (§7.5). - Testy zielone; `ruff` czysty; `pre-commit` bez uwag. - Newsfragment w `src/bpp/newsfragments/`. - Istniejące testy `oauth_mcp` i `api_v1` przechodzą **bez modyfikacji**. diff --git a/src/bpp/newsfragments/mcp-hostowany.feature.rst b/src/bpp/newsfragments/mcp-hostowany.feature.rst index c16bf1570..403fede18 100644 --- a/src/bpp/newsfragments/mcp-hostowany.feature.rst +++ b/src/bpp/newsfragments/mcp-hostowany.feature.rst @@ -1,4 +1,5 @@ Serwis wystawia własny serwer MCP pod adresem ``/mcp`` (publiczny) oraz ``/mcp/auth`` (z logowaniem OAuth). Asystenci AI obsługujący zdalne serwery MCP łączą się jednym adresem — bez instalowania czegokolwiek i bez podawania -adresu uczelni. Instrukcja podłączenia jest na stronie ``/mcp/``. +adresu uczelni. Instrukcja podłączenia jest na stronie ``/mcp/``. Stan samego +endpointu (niezależny od sondy ``/health/`` Dockera) pokazuje ``/mcp/status``. From fc7ab5fa7db2319e858d98810ae7a683e7d2e8f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sun, 6 Sep 2026 21:40:25 +0200 Subject: [PATCH 28/29] =?UTF-8?q?fix(mcp=5Fserver):=20domkni=C4=99cie=20dw?= =?UTF-8?q?=C3=B3ch=20usterek=20z=20recenzji=20fali=20naprawczej?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU --- src/mcp_server/routing.py | 32 ++++++++- src/mcp_server/tests/test_bramka_uczelni.py | 72 +++++++++++++++++++++ src/mcp_server/tests/test_e2e.py | 19 +++++- 3 files changed, 119 insertions(+), 4 deletions(-) diff --git a/src/mcp_server/routing.py b/src/mcp_server/routing.py index 461112031..180a24ed6 100644 --- a/src/mcp_server/routing.py +++ b/src/mcp_server/routing.py @@ -6,8 +6,10 @@ import logging import time +import rollbar from bpp_mcp.auth import set_current_bearer from django.conf import settings +from django.db import Error as BladBazy from django_bpp.client_ip import get_client_ip from mcp_server.auth import BramkaBearera @@ -64,6 +66,9 @@ def __init__(self, mcp_app, django_app, start: StartMcp) -> None: self._start = start self._publiczny = BramkaBearera(mcp_app, wymagany=False) self._z_logowaniem = BramkaBearera(mcp_app, wymagany=True) + # Rate-limit zgłoszeń Rollbara przy awarii bazy w bramce uczelni + # (patrz ``_obsluz``) — patrz komentarz tam, dlaczego per instancję. + self._blad_bazy_uczelni_zgloszony = False async def __call__(self, scope, receive, send): sciezka = scope.get("path", "") @@ -129,7 +134,32 @@ async def _obsluz(self, sciezka, scope, receive, send, dane: DaneZadania): # Fail-closed na tożsamości uczelni (spec §7.2) — patrz # ``mcp_server.uczelnia``: host, dla którego nie da się rozstrzygnąć # uczelni, otwierałby bramkę API na oścież. - if not await host_rozstrzyga_uczelnie(dane.host): + try: + rozstrzygnieta = await host_rozstrzyga_uczelnie(dane.host) + except BladBazy: + # Bez tego `except` awaria bazy leciała poza aplikację ASGI jako + # gołe 500 bez treści i bez zgłoszenia do Rollbara (middleware + # Django nie jest na tej ścieżce) — wprost sprzeczne z B3, które + # ten sam diff wprowadził dla `zapewnij()`. Celujemy w + # `django.db.Error`, NIE w gołe `Exception`: szerszy złap + # przykryłby też błędy programistyczne w `host_rozstrzyga_uczelnie`. + # + # Rate-limit jak w `StartMcp._trzymaj` (B3, komentarz tam) — + # baza może leżeć dłużej niż jedno żądanie, a bez ograniczenia + # zgłaszalibyśmy Rollbarowi raz na KAŻDE żądanie w oknie awarii, + # czyli dokładnie ten hałas, który B2 usunęło z narzędzi. W + # odróżnieniu od startu menedżera, tu żądania NIE kończą się — + # więc zgłaszamy raz na epizod i resetujemy przy najbliższym + # powodzeniu, żeby kolejna, odrębna awaria też trafiła do + # Rollbara. + if not self._blad_bazy_uczelni_zgloszony: + logger.exception("mcp: awaria bazy w bramce uczelni") + rollbar.report_exc_info() + self._blad_bazy_uczelni_zgloszony = True + await self._niedostepny(send) + return + self._blad_bazy_uczelni_zgloszony = False + if not rozstrzygnieta: logger.warning( "mcp: odrzucony host bez jednoznacznej uczelni: %r", dane.host ) diff --git a/src/mcp_server/tests/test_bramka_uczelni.py b/src/mcp_server/tests/test_bramka_uczelni.py index c5fb849bd..52606420b 100644 --- a/src/mcp_server/tests/test_bramka_uczelni.py +++ b/src/mcp_server/tests/test_bramka_uczelni.py @@ -17,8 +17,12 @@ gdzie fallback na ``SITE_ID`` jest legalny. """ +from unittest.mock import Mock + import pytest +from django.db import OperationalError +from mcp_server import routing from mcp_server.routing import RouterHttp from mcp_server.start import StartMcp from mcp_server.tests.utils import uruchom, wywolaj, zbuduj_scope @@ -110,3 +114,71 @@ def test_strona_dla_czlowieka_nie_jest_bramkowana(uczelnia1, uczelnia2): lambda: wywolaj(router, zbuduj_scope("/mcp/", metoda="GET", host="appserver")) ) assert status == 200 and tresc == "DJANGO" + + +async def _wybuchaj_baza(_host): + """Zastępuje ``host_rozstrzyga_uczelnie`` — symuluje padniętą bazę.""" + raise OperationalError("baza nie odpowiada") + + +def test_awaria_bazy_w_bramce_daje_503_nie_wyciekajacy_wyjatek(monkeypatch): + """Bloker: ``host_rozstrzyga_uczelnie`` sięga do bazy, a wywołanie w + ``_obsluz`` nie miało żadnej obsługi błędu — ``OperationalError`` 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). To jest NOWY tryb awarii: + przed falą naprawczą anonimowe żądanie na ``/mcp`` wcale nie dotykało + bazy w warstwie routingu. Ma dać kontrolowane 503, tak jak awaria startu + menedżera (B3), z zgłoszeniem do Rollbara.""" + mock_rollbar = Mock() + monkeypatch.setattr(routing, "host_rozstrzyga_uczelnie", _wybuchaj_baza) + monkeypatch.setattr(routing, "rollbar", mock_rollbar) + + router = RouterHttp(_mcp, _django, StartMcp(_AtrapaLifespanu())) + status, _, tresc = uruchom(lambda: wywolaj(router, zbuduj_scope("/mcp"))) + + assert status == 503 + assert "MCP" not in tresc + mock_rollbar.report_exc_info.assert_called_once() + + +def test_awaria_bazy_w_bramce_zglaszana_raz_na_epizod_nie_na_zadanie(monkeypatch): + """Nie odtwarzaj B2: gdy baza leży dłużej niż jedno żądanie, Rollbar ma + dostać JEDNO zgłoszenie na epizod awarii, a nie jedno na każde odrzucone + żądanie w pętli — inaczej martwa baza sama wyczerpałaby kwotę Rollbara + (tak jak `BppError` przed B2, zmierzone: 4 zgłoszenia na 4 wywołania).""" + mock_rollbar = Mock() + monkeypatch.setattr(routing, "host_rozstrzyga_uczelnie", _wybuchaj_baza) + monkeypatch.setattr(routing, "rollbar", mock_rollbar) + + router = RouterHttp(_mcp, _django, StartMcp(_AtrapaLifespanu())) + + async def scenariusz(): + for _ in range(3): + await wywolaj(router, zbuduj_scope("/mcp")) + + uruchom(scenariusz) + mock_rollbar.report_exc_info.assert_called_once() + + +def test_awaria_bazy_w_bramce_zglaszana_ponownie_po_odzyskaniu(monkeypatch): + """Po powrocie bazy do życia flaga rate-limitu wraca do zera — kolejny, + ODRĘBNY epizod awarii musi znowu trafić do Rollbara, inaczej pierwsza + awaria na wiele godzin uciszałaby monitoring na resztę życia workera.""" + mock_rollbar = Mock() + monkeypatch.setattr(routing, "host_rozstrzyga_uczelnie", _wybuchaj_baza) + monkeypatch.setattr(routing, "rollbar", mock_rollbar) + + router = RouterHttp(_mcp, _django, StartMcp(_AtrapaLifespanu())) + + async def zdrowa(_host): + return True + + async def scenariusz(): + await wywolaj(router, zbuduj_scope("/mcp")) + monkeypatch.setattr(routing, "host_rozstrzyga_uczelnie", zdrowa) + await wywolaj(router, zbuduj_scope("/mcp")) + monkeypatch.setattr(routing, "host_rozstrzyga_uczelnie", _wybuchaj_baza) + await wywolaj(router, zbuduj_scope("/mcp")) + + uruchom(scenariusz) + assert mock_rollbar.report_exc_info.call_count == 2 diff --git a/src/mcp_server/tests/test_e2e.py b/src/mcp_server/tests/test_e2e.py index 2182bf393..a52be6642 100644 --- a/src/mcp_server/tests/test_e2e.py +++ b/src/mcp_server/tests/test_e2e.py @@ -133,12 +133,25 @@ async def dialog(): @pytest.mark.django_db(transaction=True) -def test_zle_host_daje_421(settings): - """Ochrona przed DNS-rebinding SDK działa niezależnie od ALLOWED_HOSTS.""" +def test_zle_host_daje_421(settings, uczelnia): + """Ochrona przed DNS-rebinding SDK działa niezależnie od ALLOWED_HOSTS. + + Host MUSI mieć swój ``Site`` — fixture ``uczelnia`` wiąże go z domeną + "testserver" (``src/fixtures/conftest_models.py``) — inaczej 421 zapada + w NASZEJ bramce uczelni (``mcp_server.uczelnia``, patrz + ``test_bramka_uczelni.py``), zanim żądanie w ogóle dojdzie do SDK, i test + niczego nie dowodzi o warstwie, którą deklaruje sprawdzać (zweryfikowane: + bez tej fixture baza jest pusta, więc 421 zapadał u nas — przy wyłączonej + ochronie SDK, ``_dozwolone_hosty() == []``, cała reszta suity + ``mcp_server`` była zielona, więc NIC nie pokrywało sprawdzenia hosta + przez SDK na żywym żądaniu). "testserver" jest jednak SPOZA + ``ALLOWED_HOSTS`` (``_router`` ustawia je na ``["bpp.example.test"]``), + więc odrzucenie musi przyjść z ``TransportSecuritySettings`` SDK. + """ router = _router(settings) scope = zbuduj_scope( "/mcp", - host="zly.host", + host="testserver", naglowki={"content-type": "application/json"}, ) status, _, _ = uruchom( From 8ff2250210c13507a18502d031407c243fe14d3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sun, 6 Sep 2026 22:45:33 +0200 Subject: [PATCH 29/29] =?UTF-8?q?fix(mcp=5Fserver):=20self-review=20PR=20#?= =?UTF-8?q?804=20=E2=80=94=20Rollbar=205xx=20i=20siatka=20bezpiecze=C5=84s?= =?UTF-8?q?twa?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01BcGwNydVPk4jcNUYZ8pUNU --- src/mcp_server/aplikacja.py | 56 +++++++++----- src/mcp_server/routing.py | 73 ++++++++++++++++--- src/mcp_server/tests/test_bramka_uczelni.py | 81 +++++++++++++++++++++ src/mcp_server/tests/test_rollbar.py | 80 ++++++++++++++++++-- src/mcp_server/tests/test_routing.py | 27 +++++++ 5 files changed, 279 insertions(+), 38 deletions(-) diff --git a/src/mcp_server/aplikacja.py b/src/mcp_server/aplikacja.py index ee011cb60..085a27200 100644 --- a/src/mcp_server/aplikacja.py +++ b/src/mcp_server/aplikacja.py @@ -97,20 +97,42 @@ def _z_raportowaniem(serwer: MCPServer) -> MCPServer: zarejestrowałby ``ctx`` jako zwykły, wymagany parametr wejściowy zamiast wstrzykiwanego kontekstu. - **Co NIE jest raportowane: ``BppError``.** W ``bpp-mcp`` to normalny kanał - komunikatów do użytkownika, nie awaria — 401 anonima przy DjangoQL, 404 - nieistniejącej encji, odrzucony argument, przekroczony budżet czasu. Na - nieuwierzytelnionym, publicznym endpoincie raportowanie ich znaczyłoby, że - dowolna osoba z internetu wyczerpuje kwotę Rollbara jednym ``curl``-em - w pętli i topi realne alerty (zmierzone przez recenzenta: 4 zgłoszenia na - 4 wywołania). Okno maintenance albo niedokończony kreator dawałyby - zgłoszenie z KAŻDEGO wywołania. - - Wyjątek od wyjątku: ``BppError`` ze statusem 5xx. Ten status pochodzi - z odpowiedzi NASZEJ aplikacji Django na żądanie wewnętrzne — czyli mówi - o awarii po naszej stronie, nie o błędzie pytającego. Takie zgłoszenie - jest wprost tym, co monitoring ma zobaczyć, a wywołać je może wyłącznie - faktyczna awaria serwisu (użytkownik nie ma jak wymusić 5xx z /api/v1/). + **``BppError`` NIGDY nie jest raportowany — także przy statusie 5xx.** + W ``bpp-mcp`` to normalny kanał komunikatów do użytkownika, nie awaria — + 401 anonima przy DjangoQL, 404 nieistniejącej encji, odrzucony argument, + przekroczony budżet czasu. Na nieuwierzytelnionym, publicznym endpoincie + raportowanie ich znaczyłoby, że dowolna osoba z internetu wyczerpuje + kwotę Rollbara jednym ``curl``-em w pętli i topi realne alerty (zmierzone + przez recenzenta: 4 zgłoszenia na 4 wywołania). Okno maintenance albo + niedokończony kreator dawałyby zgłoszenie z KAŻDEGO wywołania. + + Wcześniejsza wersja tej funkcji robiła wyjątek dla statusu >= 500, z + uzasadnieniem, że taki status pochodzi z odpowiedzi naszej aplikacji + Django na żądanie wewnętrzne, więc mówi o awarii po naszej stronie, a + „użytkownik nie ma jak wymusić 5xx z /api/v1/”. Recenzent zmierzył + empirycznie, że ta gałąź działała ODWROTNIE do zamierzenia: + + * Timeout DjangoQL (``statement_timeout`` w + ``api_v1/viewsets/zapytanie.py``) daje 503 DETERMINISTYCZNIE przy zbyt + szerokim zapytaniu — dowolny anonim wymusza go samym zapytaniem. + ``retry_5xx=False`` na tej ścieżce (``bpp_mcp/client.py``) przenosi + ``status_code=503`` aż do ``BppError``, więc trafiał do Rollbara przy + KAŻDYM wywołaniu (zmierzone: 3 wywołania = 3 zgłoszenia) — czyli + dokładnie ten hałas od anonima, który reszta tej funkcji ma wygaszać. + * Prawdziwe 500 z Django, przeciwnie, NIE trafiało do Rollbara: narzędzia + z ``retry_5xx=True`` (wszystkie poza ``zapytanie_*``) po wyczerpaniu + prób gubią ``status_code`` w końcowym + ``raise BppNetworkError("Nie udało się pobrać ... po N próbach")`` + (``bpp_mcp/client.py``) — warunek ``status >= 500`` nigdy nie był + prawdziwy tam, gdzie miał być. + + Gałąź usunięto całkowicie — ``BppError`` idzie tą samą, jedną ścieżką + niezależnie od statusu. Realne 500 i tak nie jest ślepą plamą: żądanie + do ``/api/v1/`` idzie przez własną aplikację Django W PROCESIE + (``mcp_server.klient``), a na TEJ ścieżce Django biegnie normalnie — + ``CustomRollbarNotifierMiddleware`` (``bpp.middleware``) zgłasza wyjątek + z żądania wewnętrznego do Rollbara, zanim ``BppClient`` zdąży zamienić + odpowiedź na czytelny ``BppError``. """ oryginalny = serwer.tool @@ -122,10 +144,8 @@ def opakuj(fn): async def wrapper(*a, **kw): try: return await fn(*a, **kw) - except BppError as exc: - status = getattr(exc, "status_code", None) - if status is not None and status >= 500: - rollbar.report_exc_info() + except BppError: + # Nigdy nie raportuj — patrz docstring funkcji. raise except Exception: rollbar.report_exc_info() diff --git a/src/mcp_server/routing.py b/src/mcp_server/routing.py index 180a24ed6..123fca016 100644 --- a/src/mcp_server/routing.py +++ b/src/mcp_server/routing.py @@ -10,6 +10,7 @@ from bpp_mcp.auth import set_current_bearer from django.conf import settings from django.db import Error as BladBazy +from redis.exceptions import ConnectionError as BladRedisa from django_bpp.client_ip import get_client_ip from mcp_server.auth import BramkaBearera @@ -66,8 +67,9 @@ def __init__(self, mcp_app, django_app, start: StartMcp) -> None: self._start = start self._publiczny = BramkaBearera(mcp_app, wymagany=False) self._z_logowaniem = BramkaBearera(mcp_app, wymagany=True) - # Rate-limit zgłoszeń Rollbara przy awarii bazy w bramce uczelni - # (patrz ``_obsluz``) — patrz komentarz tam, dlaczego per instancję. + # Rate-limit zgłoszeń Rollbara przy awarii bazy/cache w bramce + # uczelni (patrz ``_obsluz``, obsługuje ``BladBazy`` ORAZ + # ``BladRedisa``) — komentarz tam wyjaśnia, dlaczego per instancję. self._blad_bazy_uczelni_zgloszony = False async def __call__(self, scope, receive, send): @@ -83,7 +85,6 @@ async def __call__(self, scope, receive, send): await self._przekieruj(send, "/mcp/") return - dane = self._dane(scope) poczatek = time.monotonic() # Kod odpowiedzi znamy dopiero z komunikatu wysłanego przez warstwę # niżej — podglądamy go w drodze na zewnątrz, żeby log audytowy @@ -97,17 +98,42 @@ async def sledzacy(komunikat): widziany["status"] = komunikat["status"] await send(komunikat) + # `dane` wyliczamy DOPIERO wewnątrz siatki bezpieczeństwa (nie przed + # `try` jak wcześniej) — usterka 2a z self-review PR #804: + # `_dane(scope)` woła bez `errors=` na `.decode()`, więc niepoprawny + # UTF-8 w nagłówku (np. `Authorization: Bearer \xff`) rzucał + # `UnicodeDecodeError` PRZED jakimkolwiek `try`, co dawało gołe 500 + # bez wpisu w logu audytowym i bez Rollbara — anonimowo, jednym + # żądaniem. Poprawka niżej (`errors="replace"`) usuwa tę KONKRETNĄ + # przyczynę, ale `dane` zostaje w siatce jako obrona w głąb: kolejny + # nieoczywisty błąd w tej funkcji też dostanie log + zgłoszenie + # + kontrolowane 503, nie gołe 500. + dane = None try: + dane = self._dane(scope) await self._obsluz(sciezka, scope, receive, sledzacy, dane) + except Exception: + # JEDNA siatka bezpieczeństwa na całą obsługę żądania — patrz + # komentarz nad `except (BladBazy, BladRedisa)` w `_obsluz` o + # tym, dlaczego TA gałąź nie duplikuje raportowania z węższych + # except-ów: te kończą się (return) bez re-raise, więc do tego + # miejsca dochodzi wyłącznie to, czego węższy except NIE złapał. + logger.exception("mcp: nieobsłużony wyjątek w obsłudze żądania") + rollbar.report_exc_info() + if "status" not in widziany: + # Nie próbuj wysyłać drugiego `http.response.start` — ASGI + # nie pozwala na dwa starty odpowiedzi, a warstwa niżej może + # już swój wysłać (np. gdzieś w środku strumieniowania). + await self._niedostepny(sledzacy) finally: logger.info( "mcp %s %s host=%s bearer=%s status=%s czas=%.3fs", scope.get("method", "?"), sciezka, - dane.host, + dane.host if dane is not None else "?", # OBECNOŚĆ, nigdy wartość — token w logu byłby poświadczeniem # leżącym w pliku, który wędruje do agregatora i backupów. - "tak" if dane.bearer else "nie", + "tak" if dane is not None and dane.bearer else "nie", widziany.get("status"), time.monotonic() - poczatek, ) @@ -136,13 +162,24 @@ async def _obsluz(self, sciezka, scope, receive, send, dane: DaneZadania): # uczelni, otwierałby bramkę API na oścież. try: rozstrzygnieta = await host_rozstrzyga_uczelnie(dane.host) - except BladBazy: - # Bez tego `except` awaria bazy leciała poza aplikację ASGI jako - # gołe 500 bez treści i bez zgłoszenia do Rollbara (middleware + except (BladBazy, BladRedisa): + # Bez tego `except` awaria bazy/cache leciała poza aplikację ASGI + # jako gołe 500 bez treści i bez zgłoszenia do Rollbara (middleware # Django nie jest na tej ścieżce) — wprost sprzeczne z B3, które - # ten sam diff wprowadził dla `zapewnij()`. Celujemy w - # `django.db.Error`, NIE w gołe `Exception`: szerszy złap - # przykryłby też błędy programistyczne w `host_rozstrzyga_uczelnie`. + # ten sam diff wprowadził dla `zapewnij()`. Celujemy w konkretne + # typy (`django.db.Error` ORAZ `redis.exceptions.ConnectionError`), + # NIE w gołe `Exception` — dla drugiego mamy siatkę bezpieczeństwa + # w `__call__`, a tu szerszy złap przykryłby błędy programistyczne + # w `host_rozstrzyga_uczelnie` i pozbawił je jej raportowania + # (patrz tamten `except Exception`, bez rate-limitu poniżej). + # + # `BladRedisa` jest tu obok `BladBazy`, nie osobno: `sites.site` + # i `bpp.uczelnia` są w `CACHEOPS` (`production.py`), a + # `CACHEOPS_DEGRADE_ON_FAILURE` nie jest ustawione, więc leżący + # Redis daje `redis.exceptions.ConnectionError` z samego ORM-u + # (nie z bazy) — `except BladBazy` samo w sobie by tego nie + # złapało, mimo że to DOKŁADNIE ten sam epizod „infrastruktura pod + # `/mcp` nie odpowiada", który ten blok ma obsłużyć. # # Rate-limit jak w `StartMcp._trzymaj` (B3, komentarz tam) — # baza może leżeć dłużej niż jedno żądanie, a bez ograniczenia @@ -221,7 +258,19 @@ def _dane(scope) -> DaneZadania: # i ZAWSZE trafia w wartość domyślną. Skutek po cichu: bearer # zawsze None, host zawsze "localhost" — mostek z pkt 2 briefu # wygląda podłączony, a nic nie przenosi. - naglowki = {k.lower().decode(): v.decode() for k, v in scope.get("headers", [])} + # + # UWAGA 2 (usterka 2a z self-review PR #804): ``errors="replace"`` na + # WARTOŚCI jest wymagane — nagłówek to dane od klienta, nie coś, czemu + # ufamy jako poprawnemu UTF-8. Bez tego `curl -H 'Authorization: + # Bearer \xff'` dawał `UnicodeDecodeError` tu, w miejscu wywoływanym + # PRZED jakąkolwiek siatką bezpieczeństwa w `__call__` — anonimowo, + # jednym żądaniem, bez wpisu w logu i bez Rollbara. Ten sam wybór + # (`errors="replace"`) robi już `mcp_server.auth.BramkaBearera` — + # jedno źródło prawdy dla obu miejsc. + naglowki = { + k.lower().decode(): v.decode(errors="replace") + for k, v in scope.get("headers", []) + } host = naglowki.get("host", "localhost") # nginx→uvicorn jest plaintext; prawdziwy schemat niesie nagłówek # wskazany przez SECURE_PROXY_SSL_HEADER (spec §7.6). Nagłówka NIE diff --git a/src/mcp_server/tests/test_bramka_uczelni.py b/src/mcp_server/tests/test_bramka_uczelni.py index 52606420b..0e6788f53 100644 --- a/src/mcp_server/tests/test_bramka_uczelni.py +++ b/src/mcp_server/tests/test_bramka_uczelni.py @@ -21,6 +21,7 @@ import pytest from django.db import OperationalError +from redis.exceptions import ConnectionError as BladRedisa from mcp_server import routing from mcp_server.routing import RouterHttp @@ -182,3 +183,83 @@ async def scenariusz(): uruchom(scenariusz) assert mock_rollbar.report_exc_info.call_count == 2 + + +async def _wybuchaj_redis(_host): + """Zastępuje ``host_rozstrzyga_uczelnie`` — symuluje padłego Redisa pod + cacheops (nie bazę: to jest INNY typ wyjątku od ``_wybuchaj_baza``).""" + raise BladRedisa("Redis nie odpowiada") + + +def test_awaria_redis_w_bramce_daje_503_tak_jak_awaria_bazy(monkeypatch): + """Usterka 2c z self-review PR #804: ``sites.site`` i ``bpp.uczelnia`` + są w ``CACHEOPS`` (``production.py``), a ``CACHEOPS_DEGRADE_ON_FAILURE`` + nie jest ustawione — leżący Redis daje ``redis.exceptions.ConnectionError`` + Z SAMEGO ORM-u, nie ``django.db.Error``. Wąski ``except BladBazy`` (przed + poprawką) tego NIE łapał, więc ta awaria wychodziła poza aplikację ASGI + jako gołe 500 — mimo że to dokładnie ten sam epizod „infrastruktura pod + /mcp nie odpowiada", który ``except BladBazy`` miał obsłużyć. Musi dać + to samo kontrolowane 503 + zgłoszenie, co awaria bazy.""" + mock_rollbar = Mock() + monkeypatch.setattr(routing, "host_rozstrzyga_uczelnie", _wybuchaj_redis) + monkeypatch.setattr(routing, "rollbar", mock_rollbar) + + router = RouterHttp(_mcp, _django, StartMcp(_AtrapaLifespanu())) + status, _, tresc = uruchom(lambda: wywolaj(router, zbuduj_scope("/mcp"))) + + assert status == 503 + assert "MCP" not in tresc + mock_rollbar.report_exc_info.assert_called_once() + + +def test_awaria_redis_w_bramce_zglaszana_raz_na_epizod_nie_na_zadanie(monkeypatch): + """Rozstrzyga, KTÓRY except faktycznie złapał ``BladRedisa`` — jedno + żądanie (test wyżej) dałoby 503 + jedno zgłoszenie NIEZALEŻNIE od tego, + czy złapał go rate-limitowany ``except (BladBazy, BladRedisa)`` w + ``_obsluz``, czy ogólna siatka bezpieczeństwa w ``__call__`` (ta zgłasza + BEZ rate-limitu). Trzy żądania w tym samym epizodzie odróżniają te dwa + przypadki: rate-limitowany except da JEDNO zgłoszenie (jak dla + ``BladBazy`` — patrz ``test_awaria_bazy_w_bramce_zglaszana_raz_na_epizod_ + nie_na_zadanie``), a sama ogólna siatka dałaby TRZY.""" + mock_rollbar = Mock() + monkeypatch.setattr(routing, "host_rozstrzyga_uczelnie", _wybuchaj_redis) + monkeypatch.setattr(routing, "rollbar", mock_rollbar) + + router = RouterHttp(_mcp, _django, StartMcp(_AtrapaLifespanu())) + + async def scenariusz(): + for _ in range(3): + await wywolaj(router, zbuduj_scope("/mcp")) + + uruchom(scenariusz) + mock_rollbar.report_exc_info.assert_called_once() + + +async def _wybuchaj_niespodziewanie(_host): + """Symuluje błąd PROGRAMISTYCZNY (np. literówkę w nowym kodzie), nie + znaną awarię infrastruktury — ma sprawdzić SZERSZĄ siatkę bezpieczeństwa + w ``RouterHttp.__call__``, różną od wąskiego + ``except (BladBazy, BladRedisa)`` w ``_obsluz``.""" + raise RuntimeError("błąd programistyczny w host_rozstrzyga_uczelnie") + + +def test_niespodziewany_wyjatek_w_obsludze_daje_503_i_trafia_do_rollbara( + monkeypatch, +): + """Usterka 2c z self-review PR #804: komentarz przy wąskim + ``except BladBazy`` uzasadniał go obawą o przykrycie błędów + programistycznych — ale błąd programistyczny wychodzący do uvicorna jako + gołe 500 bez Rollbara jest GORSZY niż zaraportowane 503. Siatka + bezpieczeństwa w ``__call__`` (``except Exception``) ma złapać WSZYSTKO, + co nie trafiło do węższych except-ów — zaraportować i odpowiedzieć + kontrolowanym 503, a nie połknąć po cichu jako gołe 500.""" + mock_rollbar = Mock() + monkeypatch.setattr(routing, "host_rozstrzyga_uczelnie", _wybuchaj_niespodziewanie) + monkeypatch.setattr(routing, "rollbar", mock_rollbar) + + router = RouterHttp(_mcp, _django, StartMcp(_AtrapaLifespanu())) + status, _, tresc = uruchom(lambda: wywolaj(router, zbuduj_scope("/mcp"))) + + assert status == 503 + assert "MCP" not in tresc + mock_rollbar.report_exc_info.assert_called_once() diff --git a/src/mcp_server/tests/test_rollbar.py b/src/mcp_server/tests/test_rollbar.py index feb2a90d1..5217b0c39 100644 --- a/src/mcp_server/tests/test_rollbar.py +++ b/src/mcp_server/tests/test_rollbar.py @@ -19,12 +19,15 @@ from unittest.mock import Mock import bpp_mcp.tools as bpp_tools +import httpx import pytest from bpp_mcp.client import BppError from mcp.server.mcpserver.exceptions import ToolError, UnexpectedToolError from mcp_server import aplikacja from mcp_server.aplikacja import build_application +from mcp_server.klient import BppClientInProcess +from mcp_server.kontekst import DaneZadania, dane_zadania from mcp_server.tests.utils import uruchom @@ -122,15 +125,76 @@ def test_bpperror_bez_statusu_nie_trafia_do_rollbara(monkeypatch): mock_rollbar.report_exc_info.assert_not_called() -def test_bpperror_5xx_jednak_trafia_do_rollbara(monkeypatch): - """Granica wyjątku od wyjątku: status 5xx pochodzi z odpowiedzi NASZEJ - aplikacji Django na żądanie wewnętrzne, więc mówi o awarii po naszej - stronie. Pytający nie ma jak jej wymusić z ``/api/v1/``, więc ta gałąź - nie daje się użyć do zalania Rollbara.""" - mock_rollbar = _wywolaj_djangoql_schema( - monkeypatch, _rzucajacy_bpperror(status=503), ToolError +def test_bpperror_5xx_z_prawdziwego_klienta_nie_trafia_do_rollbara(monkeypatch): + """Regresja na usterkę z self-review PR #804: wcześniejsza wersja + ``_z_raportowaniem`` robiła wyjątek dla ``BppError`` ze statusem 5xx, z + uzasadnieniem że taki status mówi o awarii po NASZEJ stronie i „użytkownik + nie ma jak wymusić 5xx z /api/v1/”. Recenzent zmierzył empirycznie, że jest + ODWROTNIE: timeout DjangoQL (``statement_timeout`` w + ``api_v1/viewsets/zapytanie.py``) daje 503 DETERMINISTYCZNIE przy zbyt + szerokim zapytaniu, a ``retry_5xx=False`` na tej ścieżce + (w zainstalowanym ``bpp-mcp``) przenosi ten status aż do ``BppError`` — + 3 wywołania dawały 3 zgłoszenia Rollbara, czyli ten sam hałas od anonima, + który reszta tej funkcji ma wygaszać. Gałąź usunięto całkowicie. + + Test idzie przez PRAWDZIWY ``BppClientInProcess`` (mockowany transport + HTTP zwracający 503, nie wstrzyknięty wyjątek) — poprzednia wersja tego + testu wstrzykiwała ``BppError(status_code=503)`` ręcznie prosto do + narzędzia, co niczego nie dowodziło: klient produkuje taki obiekt + wyłącznie dla tej jednej, konkretnej ścieżki (DjangoQL), a nie w ogóle + dla „realnego 500”. + + Wołamy ``narzedzie.fn`` (czyli nasz ``wrapper``) wprost, z ATRAPĄ + ``Context`` — ``serwer.call_tool()`` (użyte w innych testach tego modułu) + buduje ``Context`` bez ``request_context``, a ``zapytanie_rekord`` (w + odróżnieniu od ``djangoql_schema``) go potrzebuje (``_client(ctx)`` w + zainstalowanym ``bpp-mcp``), więc poleciałby ``ValueError`` niezwiązany + z tezą tego testu.""" + mock_rollbar = Mock() + monkeypatch.setattr(aplikacja, "rollbar", mock_rollbar) + + def odpowiedz_503(request): + return httpx.Response(503, json={"error": "statement_timeout"}) + + monkeypatch.setattr( + aplikacja, + "zbuduj_klienta", + lambda: BppClientInProcess(transport=httpx.MockTransport(odpowiedz_503)), ) - mock_rollbar.report_exc_info.assert_called_once() + + mcp_app, _ = build_application() + serwer = mcp_app.state.serwer_mcp + narzedzie = serwer._tool_manager.get_tool("zapytanie_rekord") + + class _AtrapaRequestContext: + def __init__(self, lifespan_context): + self.request = None + self.lifespan_context = lifespan_context + + class _AtrapaContext: + def __init__(self, lifespan_context): + self.request_context = _AtrapaRequestContext(lifespan_context) + + async def scenariusz(): + zeton = dane_zadania.set( + DaneZadania( + host="bpp.example.test", + scheme="https", + ip="198.51.100.9", + bearer=None, + deadline=None, + ) + ) + try: + ctx = _AtrapaContext(aplikacja.KontekstZadania()) + with pytest.raises(BppError) as exc_info: + await narzedzie.fn(ctx, "rok=2020") + assert exc_info.value.status_code == 503 + finally: + dane_zadania.reset(zeton) + + uruchom(scenariusz) + mock_rollbar.report_exc_info.assert_not_called() def test_context_i_schemat_przetrwaly_wrapper(): diff --git a/src/mcp_server/tests/test_routing.py b/src/mcp_server/tests/test_routing.py index f7fe61b2c..b3b95f904 100644 --- a/src/mcp_server/tests/test_routing.py +++ b/src/mcp_server/tests/test_routing.py @@ -5,6 +5,8 @@ Dopasowanie prefiksowe byłoby dodatkowo sprzeczne z §8: /mcp/ ma iść do Django. """ +import logging + import pytest from mcp_server.routing import LifespanMcp, RouterHttp @@ -226,3 +228,28 @@ async def send(m): "lifespan.startup.complete", "lifespan.shutdown.complete", ] + + +@pytest.mark.django_db(transaction=True) +def test_naglowek_z_niepoprawnym_utf8_nie_wycieka_wyjatku(caplog, uczelnia): + """Usterka 2a z self-review PR #804: ``curl -H $'Authorization: Bearer + \\xff' https:///mcp`` dawał ``UnicodeDecodeError`` z + ``_dane()`` — wywoływanego w ``__call__`` PRZED jakąkolwiek siatką + bezpieczeństwa, więc wyjątek wychodził poza aplikację ASGI jako gołe 500 + bez treści, bez wpisu w logu audytowym i bez Rollbara. Naprawa: + ``errors="replace"``, tak jak już robi ``mcp_server.auth`` (patrz + ``BramkaBearera._odmow``). + + Bearer po zamianie na znak zastępczy jest po prostu NIEPRAWIDŁOWYM + tokenem — odpowiedź to zwykłe, kontrolowane 401, nie wyjątek.""" + scope = zbuduj_scope("/mcp") + scope["headers"] = [*scope["headers"], (b"authorization", b"Bearer \xff")] + + caplog.set_level(logging.INFO, logger="mcp_server.routing") + router = RouterHttp(_echo_sciezki, _django, StartMcp(_AtrapaLifespanu())) + status, _, _ = uruchom(lambda: wywolaj(router, scope)) + + assert status == 401 + # Wpis audytowy (spec §7.5) MUSI powstać, mimo błędu w nagłówku — + # w buggy wersji finally nigdy nie był osiągany. + assert any("mcp POST /mcp" in rekord.message for rekord in caplog.records)