ffbb-mcp-server 1.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ffbb_mcp/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """FFBB MCP Server — Fédération Française de Basketball."""
2
+
3
+ from importlib.metadata import PackageNotFoundError
4
+ from importlib.metadata import version as _pkg_version
5
+
6
+ try:
7
+ __version__ = _pkg_version("ffbb-mcp-server")
8
+ except PackageNotFoundError:
9
+ __version__ = "unknown"
ffbb_mcp/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Entry point for the FFBB MCP server."""
2
+
3
+ from .server import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
ffbb_mcp/_state.py ADDED
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass, field
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ if TYPE_CHECKING:
8
+ import asyncio
9
+
10
+ from cachetools import TLRUCache, TTLCache
11
+
12
+
13
+ def _read_positive_int_env(key: str, default: int) -> int:
14
+ val_str = os.environ.get(key)
15
+ if val_str is not None:
16
+ try:
17
+ val = int(val_str)
18
+ if val > 0:
19
+ return val
20
+ except ValueError:
21
+ pass
22
+ return default
23
+
24
+
25
+ @dataclass
26
+ class _ServiceState:
27
+ inflight_bilan: dict[str, asyncio.Task[Any]] = field(default_factory=dict)
28
+ inflight_calendrier: dict[str, asyncio.Task[Any]] = field(default_factory=dict)
29
+ inflight_poule: dict[str, asyncio.Task[Any]] = field(default_factory=dict)
30
+ inflight_detail: dict[str, asyncio.Task[Any]] = field(default_factory=dict)
31
+ inflight_search: dict[str, asyncio.Task[Any]] = field(default_factory=dict)
32
+
33
+ # Caches in-memory globaux
34
+ cache_lives: TTLCache[Any, Any] | None = None
35
+ cache_search: TTLCache[Any, Any] | None = None
36
+ cache_competition: TTLCache[Any, Any] | None = None
37
+ cache_organisme: TTLCache[Any, Any] | None = None
38
+ cache_saisons: TTLCache[Any, Any] | None = None
39
+ cache_calendrier: TTLCache[Any, Any] | TLRUCache[Any, Any] | None = None
40
+ cache_bilan: TLRUCache[Any, Any] | None = None
41
+ cache_classement: TLRUCache[Any, Any] | None = None
42
+ cache_poule: TLRUCache[Any, Any] | None = None
43
+
44
+
45
+ state = _ServiceState()
46
+
47
+
48
+ def reset_service_state() -> None:
49
+ global state
50
+ state.inflight_bilan.clear()
51
+ state.inflight_calendrier.clear()
52
+ state.inflight_poule.clear()
53
+ state.inflight_detail.clear()
54
+ state.inflight_search.clear()
55
+ if state.cache_lives is not None:
56
+ state.cache_lives.clear()
57
+ if state.cache_search is not None:
58
+ state.cache_search.clear()
59
+ if state.cache_competition is not None:
60
+ state.cache_competition.clear()
61
+ if state.cache_organisme is not None:
62
+ state.cache_organisme.clear()
63
+ if state.cache_saisons is not None:
64
+ state.cache_saisons.clear()
65
+ if state.cache_calendrier is not None:
66
+ state.cache_calendrier.clear()
67
+ if state.cache_bilan is not None:
68
+ state.cache_bilan.clear()
69
+ if state.cache_classement is not None:
70
+ state.cache_classement.clear()
71
+ if state.cache_poule is not None:
72
+ state.cache_poule.clear()
ffbb_mcp/aliases.py ADDED
@@ -0,0 +1,374 @@
1
+ """Gestion des alias et acronymes de clubs FFBB.
2
+
3
+ Ce module fournit :
4
+ - Un dictionnaire statique d'alias bien connus (CLUB_ALIASES)
5
+ - Un cache persistant d'acronymes auto-enrichi (acronyms_cache.json)
6
+ - normalize_query() : résolution d'alias dans les recherches
7
+ - resolve_acronym() : résolution spécifique d'acronymes (< 7 chars, tout en majuscules)
8
+ - enrich_acronym_cache() : enrichissement automatique après chaque recherche réussie
9
+ """
10
+
11
+ import json
12
+ import logging
13
+ import os
14
+ import re
15
+ from pathlib import Path
16
+ from threading import Lock
17
+
18
+ logger = logging.getLogger("ffbb-mcp")
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # Dictionnaire statique d'alias (toujours en lowercase)
22
+ # ---------------------------------------------------------------------------
23
+
24
+ CLUB_ALIASES = {
25
+ "jav": "jeanne d'arc de vichy",
26
+ "ja vichy": "jeanne d'arc de vichy",
27
+ "scba": "stade clermontois basket auvergne",
28
+ "scbf": "stade clermontois basket feminin",
29
+ "asvel": "lyon villeurbanne",
30
+ "ldlc asvel": "lyon villeurbanne",
31
+ "chorale": "roanne",
32
+ "cb": "cholet basket",
33
+ "jlb": "jl bourg",
34
+ "bourg": "jl bourg",
35
+ "bcm": "gravelines dunkerque",
36
+ "gravelines": "bcm gravelines dunkerque",
37
+ "pau": "elan bearnais",
38
+ "msb": "le mans sarthe basket",
39
+ "le mans": "le mans sarthe basket",
40
+ "sluc": "nancy",
41
+ "nancy": "sluc nancy",
42
+ "sig": "strasbourg",
43
+ "csp": "limoges csp",
44
+ "essm": "essm le portel",
45
+ "sqbb": "saint quentin basket ball",
46
+ "ada": "ada blois basket 41",
47
+ "alm": "alm evreux basket",
48
+ "stb": "saint thomas basket le havre",
49
+ "rmb": "rouen metropole basket",
50
+ "amsb": "aix maurienne savoie basket",
51
+ "ujap": "ujap quimper",
52
+ "urb": "union rennes basket 35",
53
+ "cep": "cep lorient basket",
54
+ "olb": "orleans loiret basket",
55
+ "esbva": "esb villeneuve d'ascq lille metropole",
56
+ "blma": "basket lattes montpellier",
57
+ "bbd": "boulazac basket dordogne",
58
+ "htv": "hyeres toulon var basket",
59
+ "tgb": "tarbes gespe bigorre",
60
+ "asa": "alliance sport alsace",
61
+ "asm": "as monaco basket",
62
+ "jsf": "jsf nanterre",
63
+ "lmb": "lille metropole basket",
64
+ "lyonso": "lyonso basket",
65
+ "avbb": "aurore vitre basket bretagne",
66
+ "rac": "rac basket premiere",
67
+ "svbd": "saint-vallier basket drôme",
68
+ "mba": "mulhouse basket agglomeration",
69
+ "besac": "besancon avenir comtois",
70
+ "cbc": "caen basket calvados",
71
+ "utlpb": "union tarbes lourdes pyrenees basket",
72
+ "eab": "etoile angers basket",
73
+ "bco": "sasp bc orchies",
74
+ "fpb": "sas fos provence basket",
75
+ "usom": "uso mondeville basket",
76
+ "cnb": "cavigal nice basket 06",
77
+ "lbb": "landerneau bretagne basket",
78
+ "cbbs": "charnay basket bourgogne sud",
79
+ "fcba": "flammes carolo basket ardennes",
80
+ "sahb": "saint-amand hainaut basket",
81
+ }
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # Expressions régulières pré-compilées (Optimisation des performances)
85
+ # ---------------------------------------------------------------------------
86
+ # Le pré-calcul d'une regex globale combinée et d'un dictionnaire de lookup
87
+ # permet d'effectuer tous les remplacements en une seule passe C.
88
+ # Cela offre un gain de performance notable (~x2 par rapport à une boucle).
89
+
90
+ _VALID_ALIASES_DICT = {
91
+ alias: official
92
+ for alias, official in CLUB_ALIASES.items()
93
+ if not re.search(r"\b" + re.escape(alias) + r"\b", official)
94
+ }
95
+
96
+ # Trie par longueur décroissante pour que les alias les plus longs matchent en priorité
97
+ _ALIASES_SORTED = sorted(_VALID_ALIASES_DICT.keys(), key=len, reverse=True)
98
+ _ALIAS_PATTERN_ALL = re.compile(
99
+ r"\b(" + "|".join(re.escape(k) for k in _ALIASES_SORTED) + r")\b"
100
+ )
101
+
102
+ # ---------------------------------------------------------------------------
103
+ # Cache persistant d'acronymes (acronyms_cache.json)
104
+ # ---------------------------------------------------------------------------
105
+
106
+ _DEFAULT_ACRONYMS = {
107
+ "ASVEL": "Villeurbanne",
108
+ "JAV": "Jeanne d'Arc de Vichy",
109
+ "SLUC": "Nancy",
110
+ "SCBA": "Stade Clermontois",
111
+ "JDA": "Dijon",
112
+ "BCM": "Gravelines",
113
+ "SIG": "Strasbourg",
114
+ "MSB": "Le Mans",
115
+ "GB": "GERZAT BASKET",
116
+ "SCBF": "STADE CLERMONTOIS BASKET FEMININ",
117
+ "CSP": "LIMOGES CSP",
118
+ "ESSM": "ESSM LE PORTEL",
119
+ "SQBB": "SAINT QUENTIN BASKET BALL",
120
+ "ADA": "ADA BLOIS BASKET 41",
121
+ "ALM": "ALM EVREUX BASKET",
122
+ "STB": "SAINT THOMAS BASKET LE HAVRE",
123
+ "RMB": "ROUEN METROPOLE BASKET",
124
+ "AMSB": "AIX MAURIENNE SAVOIE BASKET",
125
+ "UJAP": "UJAP QUIMPER",
126
+ "URB": "UNION RENNES BASKET 35",
127
+ "CEP": "CEP LORIENT BASKET",
128
+ "OLB": "ORLEANS LOIRET BASKET",
129
+ "ESBVA": "ESB VILLENEUVE D'ASCQ LILLE METROPOLE",
130
+ "BLMA": "BASKET LATTES MONTPELLIER",
131
+ "BBD": "BOULAZAC BASKET DORDOGNE",
132
+ "HTV": "HYERES TOULON VAR BASKET",
133
+ "TGB": "TARBES GESPE BIGORRE",
134
+ "ASA": "ALLIANCE SPORT ALSACE",
135
+ "ASM": "AS MONACO BASKET",
136
+ "JSF": "JSF NANTERRE",
137
+ "LMB": "LILLE METROPOLE BASKET",
138
+ "LYONSO": "LYONSO BASKET",
139
+ "AVBB": "AURORE VITRE BASKET BRETAGNE",
140
+ "RAC": "RAC BASKET PREMIERE",
141
+ "SVBD": "SAINT-VALLIER BASKET DRÔME",
142
+ "MBA": "MULHOUSE BASKET AGGLOMERATION",
143
+ "BESAC": "BESANCON AVENIR COMTOIS",
144
+ "CBC": "CAEN BASKET CALVADOS",
145
+ "UTLPB": "UNION TARBES LOURDES PYRENEES BASKET",
146
+ "EAB": "ETOILE ANGERS BASKET",
147
+ "BCO": "SASP BC ORCHIES",
148
+ "FPB": "SAS FOS PROVENCE BASKET",
149
+ "USOM": "USO MONDEVILLE BASKET",
150
+ "CNB": "CAVIGAL NICE BASKET 06",
151
+ "LBB": "LANDERNEAU BRETAGNE BASKET",
152
+ "CBBS": "CHARNAY BASKET BOURGOGNE SUD",
153
+ "FCBA": "FLAMMES CAROLO BASKET ARDENNES",
154
+ "SAHB": "SAINT-AMAND HAINAUT BASKET",
155
+ }
156
+
157
+
158
+ def _resolve_cache_dir() -> Path:
159
+ """Retourne un dossier cache utilisateur robuste et écrivable."""
160
+ xdg = os.environ.get("XDG_CACHE_HOME")
161
+ if xdg:
162
+ return Path(xdg).expanduser() / "ffbb-mcp"
163
+ return Path.home() / ".cache" / "ffbb-mcp"
164
+
165
+
166
+ _CACHE_DIR = _resolve_cache_dir()
167
+ _CACHE_FILE = _CACHE_DIR / "acronyms_cache.json"
168
+ _cache_lock = Lock()
169
+ _acronyms_cache: dict[str, str] | None = None
170
+ _acronyms_cache_upper: dict[str, str] | None = None
171
+
172
+
173
+ def _load_acronyms_cache() -> dict[str, str]:
174
+ """Charge le cache d'acronymes depuis le fichier JSON.
175
+
176
+ Si le fichier n'existe pas, l'initialise avec les valeurs par défaut.
177
+ """
178
+ global _acronyms_cache
179
+ global _acronyms_cache_upper
180
+ if _acronyms_cache is not None:
181
+ return _acronyms_cache
182
+
183
+ with _cache_lock:
184
+ # Double-check après acquisition du lock
185
+ if _acronyms_cache is not None:
186
+ return _acronyms_cache
187
+
188
+ if _CACHE_FILE.exists():
189
+ try:
190
+ data = json.loads(_CACHE_FILE.read_text(encoding="utf-8"))
191
+ if isinstance(data, dict):
192
+ _acronyms_cache_upper = {k.upper(): v for k, v in data.items()}
193
+ _acronyms_cache = data
194
+ logger.info(
195
+ "Cache d'acronymes chargé: %d entrées depuis %s",
196
+ len(data),
197
+ _CACHE_FILE,
198
+ )
199
+ return _acronyms_cache
200
+ except (json.JSONDecodeError, OSError) as e:
201
+ logger.warning(
202
+ "Erreur lecture %s: %s — réinitialisation", _CACHE_FILE, e
203
+ )
204
+
205
+ # Initialisation avec les valeurs par défaut
206
+ _acronyms_cache_upper = {k.upper(): v for k, v in _DEFAULT_ACRONYMS.items()}
207
+ _acronyms_cache = dict(_DEFAULT_ACRONYMS)
208
+ _save_acronyms_cache()
209
+ logger.info(
210
+ "Cache d'acronymes initialisé avec %d entrées par défaut",
211
+ len(_acronyms_cache),
212
+ )
213
+ return _acronyms_cache
214
+
215
+
216
+ def _save_acronyms_cache() -> None:
217
+ """Sauvegarde le cache d'acronymes dans le fichier JSON."""
218
+ if _acronyms_cache is None:
219
+ return
220
+ try:
221
+ _CACHE_DIR.mkdir(parents=True, exist_ok=True)
222
+ _CACHE_FILE.write_text(
223
+ json.dumps(_acronyms_cache, ensure_ascii=False, indent=2) + "\n",
224
+ encoding="utf-8",
225
+ )
226
+ except OSError as e:
227
+ logger.warning("Erreur sauvegarde %s: %s", _CACHE_FILE, e)
228
+
229
+
230
+ _SKIP_WORDS: frozenset[str] = frozenset(
231
+ {"de", "du", "le", "la", "les", "et", "en", "des", "aux"}
232
+ )
233
+
234
+
235
+ def _extract_initials(name: str) -> str:
236
+ """Extrait les initiales d'un nom officiel FFBB.
237
+
238
+ Prend la première lettre de chaque mot commençant par une majuscule.
239
+ Ignore les mots courants courts (de, du, le, la, les, d', l', et).
240
+ """
241
+ words = name.split()
242
+ initials = []
243
+ for w in words:
244
+ # ⚡ Bolt: Accès direct par index plutôt que de créer un tuple et
245
+ # d'appeler startswith, évitant des allocations inutiles.
246
+ # Supprimer les articles collés (d', l', D', L')
247
+ if len(w) >= 2 and w[1] == "'" and w[0] in ("d", "l", "D", "L"):
248
+ clean = w[2:]
249
+ else:
250
+ clean = w
251
+
252
+ if not clean:
253
+ continue
254
+ if clean.lower() in _SKIP_WORDS:
255
+ continue
256
+ if clean[0].isupper():
257
+ initials.append(clean[0])
258
+ return "".join(initials)
259
+
260
+
261
+ # ---------------------------------------------------------------------------
262
+ # API publique
263
+ # ---------------------------------------------------------------------------
264
+
265
+
266
+ def resolve_acronym(query: str) -> str:
267
+ """Résout un acronyme de club vers son nom complet.
268
+
269
+ Règle de détection : si le terme est entièrement en majuscules
270
+ et fait moins de 7 caractères, tenter une résolution via le cache.
271
+
272
+ Retourne le nom résolu si trouvé, sinon retourne `query` tel quel.
273
+ """
274
+ if not query or len(query) >= 7:
275
+ return query
276
+
277
+ stripped = query.strip()
278
+ if not stripped:
279
+ return query
280
+
281
+ # Vérifier que c'est bien un acronyme (tout en majuscules, lettres uniquement)
282
+ if not stripped.isalpha() or not stripped.isupper():
283
+ return query
284
+
285
+ _load_acronyms_cache()
286
+
287
+ # Recherche case-insensitive dans le cache
288
+ assert _acronyms_cache_upper is not None
289
+ if value := _acronyms_cache_upper.get(stripped.upper()):
290
+ logger.info("Acronyme résolu: %s → %s", stripped, value)
291
+ return value
292
+
293
+ return query
294
+
295
+
296
+ def enrich_acronym_cache(official_name: str) -> None:
297
+ """Enrichit automatiquement le cache d'acronymes après une recherche réussie.
298
+
299
+ Extrait les initiales du nom officiel retourné par la FFBB.
300
+ Si ces initiales ne sont pas déjà dans le cache, les ajoute
301
+ avec le nom complet comme valeur et sauvegarde immédiatement.
302
+ """
303
+ if not official_name or len(official_name) < 3:
304
+ return
305
+
306
+ initials = _extract_initials(official_name)
307
+ if not initials or len(initials) < 2 or len(initials) >= 7:
308
+ return
309
+
310
+ cache = _load_acronyms_cache()
311
+
312
+ # Vérifier si l'acronyme existe déjà (case-insensitive)
313
+ # _load_acronyms_cache guarantees _acronyms_cache_upper is initialized
314
+ initials_upper = initials.upper()
315
+ assert _acronyms_cache_upper is not None
316
+ if initials_upper in _acronyms_cache_upper:
317
+ return
318
+
319
+ with _cache_lock:
320
+ cache[initials] = official_name
321
+ _acronyms_cache_upper[initials_upper] = official_name
322
+ _save_acronyms_cache()
323
+ logger.info("Acronyme auto-enrichi: %s → %s", initials, official_name)
324
+
325
+
326
+ _APOSTROPHES_MAP = str.maketrans("\u2019\u2018\u201b\u0060", "''''")
327
+
328
+
329
+ def _normalize_apostrophes(text: str) -> str:
330
+ """Normalise toutes les variantes typographiques d'apostrophe en apostrophe ASCII.
331
+
332
+ Variantes couvertes : \u2019 (U+2019), \u2018 (U+2018), \u201b (U+201B), \u0060 (backtick).
333
+ """
334
+ # Fast-path : la plupart des textes sont déjà ASCII et ne contiennent pas de backtick.
335
+ if text.isascii() and "`" not in text:
336
+ return text
337
+
338
+ return text.translate(_APOSTROPHES_MAP)
339
+
340
+
341
+ def normalize_query(query: str) -> str:
342
+ """Normalize a search query to replace common club abbreviations
343
+ or alternative names with their official FFBB names.
344
+ This helps the FFBB API find the correct results.
345
+
346
+ Applique aussi la résolution d'acronymes en premier.
347
+ """
348
+ if not query:
349
+ return query
350
+
351
+ # 0. Normalisation des apostrophes typographiques → apostrophe ASCII
352
+ query = _normalize_apostrophes(query)
353
+
354
+ # 1. Résolution d'acronyme en priorité
355
+ resolved = resolve_acronym(query)
356
+ if resolved != query:
357
+ return resolved
358
+
359
+ # 2. Normalisation via le dictionnaire statique
360
+ normalized = query.lower().strip()
361
+
362
+ # Try exact match first
363
+ if normalized in CLUB_ALIASES:
364
+ return CLUB_ALIASES[normalized]
365
+
366
+ # Replace whole words in a single pass using the combined regex
367
+ if _ALIAS_PATTERN_ALL.search(normalized):
368
+ normalized = _ALIAS_PATTERN_ALL.sub(
369
+ lambda m: _VALID_ALIASES_DICT[m.group(1)], normalized
370
+ )
371
+
372
+ # Remove excessive spaces
373
+ normalized = " ".join(normalized.split())
374
+ return normalized
@@ -0,0 +1,99 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import logging
5
+ import os
6
+ import re
7
+ import uuid
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ if TYPE_CHECKING:
11
+ from collections.abc import AsyncGenerator
12
+
13
+ from mcp.server.fastmcp import FastMCP
14
+
15
+ from starlette.applications import Starlette
16
+ from starlette.middleware.base import BaseHTTPMiddleware
17
+ from starlette.middleware.cors import CORSMiddleware
18
+ from starlette.middleware.gzip import GZipMiddleware
19
+ from starlette.responses import JSONResponse
20
+ from starlette.routing import Mount
21
+ from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
22
+
23
+ logger = logging.getLogger("ffbb-mcp")
24
+
25
+ _REQUEST_ID_RE = re.compile(r"^[a-zA-Z0-9\-]{1,64}$")
26
+
27
+
28
+ def create_app(mcp: FastMCP, allowed_origins: list[str]) -> Starlette:
29
+ @contextlib.asynccontextmanager
30
+ async def lifespan(app: Starlette) -> AsyncGenerator[None]:
31
+ async with mcp.session_manager.run():
32
+ yield
33
+
34
+ mcp_app = mcp.streamable_http_app()
35
+
36
+ app = Starlette(
37
+ debug=False,
38
+ routes=[Mount("/", app=mcp_app)],
39
+ lifespan=lifespan,
40
+ )
41
+ app.router.redirect_slashes = False
42
+
43
+ # Default to trusting only localhost; set TRUSTED_PROXY_HOSTS for production
44
+ # reverse-proxy scenarios (e.g. "10.0.0.1,10.0.0.2").
45
+ _proxy_hosts_raw = os.environ.get("TRUSTED_PROXY_HOSTS", "127.0.0.1")
46
+ _trusted_hosts = [h.strip() for h in _proxy_hosts_raw.split(",") if h.strip()]
47
+ app.add_middleware(
48
+ ProxyHeadersMiddleware, trusted_hosts=_trusted_hosts or ["127.0.0.1"]
49
+ )
50
+
51
+ app.add_middleware(
52
+ CORSMiddleware,
53
+ allow_origins=allowed_origins,
54
+ allow_methods=["GET", "POST", "OPTIONS", "DELETE"],
55
+ allow_headers=[
56
+ "Content-Type",
57
+ "Accept",
58
+ "Authorization",
59
+ "Mcp-Session-Id",
60
+ "MCP-Protocol-Version",
61
+ "X-Forwarded-For",
62
+ "X-Forwarded-Proto",
63
+ "X-Real-IP",
64
+ ],
65
+ expose_headers=["Content-Type", "Mcp-Session-Id"],
66
+ )
67
+
68
+ app.add_middleware(GZipMiddleware, minimum_size=1000)
69
+
70
+ class RequestIdMiddleware(BaseHTTPMiddleware):
71
+ async def dispatch(self, request: Any, call_next: Any) -> Any:
72
+ raw_id = request.headers.get("X-Request-ID", "")
73
+ request_id = raw_id if _REQUEST_ID_RE.match(raw_id) else str(uuid.uuid4())
74
+ try:
75
+ response = await call_next(request)
76
+ except Exception as e:
77
+ # Don't log broken pipes or client disconnects as errors
78
+ err_str = str(e).lower()
79
+ if any(
80
+ x in err_str
81
+ for x in ["broken pipe", "connection closed", "client disconnected"]
82
+ ):
83
+ logger.debug("Client disconnected: %s", e)
84
+ else:
85
+ logger.error(
86
+ "Middleware error on %s: %s", request.url.path, e, exc_info=True
87
+ )
88
+
89
+ response = JSONResponse(
90
+ {"error": "Internal Server Error", "request_id": request_id},
91
+ status_code=500,
92
+ )
93
+
94
+ response.headers["X-Request-ID"] = request_id
95
+ return response
96
+
97
+ app.add_middleware(RequestIdMiddleware)
98
+
99
+ return app