refactor(common): rename package option_mcp_common → mcp_common

This commit is contained in:
AdrianoDev
2026-04-27 17:37:35 +02:00
parent 563f4fc965
commit e888fc373d
48 changed files with 2394 additions and 74 deletions
+220
View File
@@ -0,0 +1,220 @@
from __future__ import annotations
import json
import os
import time
import uuid
from datetime import UTC, datetime
from collections.abc import Callable
from contextlib import AbstractAsyncContextManager
from fastapi import FastAPI, HTTPException, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse, Response
from starlette.middleware.base import BaseHTTPMiddleware
from mcp_common.auth import TokenStore
Lifespan = Callable[[FastAPI], AbstractAsyncContextManager[None]]
def _error_envelope(
*,
type_: str,
code: str,
message: str,
retryable: bool,
suggested_fix: str | None = None,
details: dict | None = None,
request_id: str | None = None,
) -> dict:
env = {
"error": {
"type": type_,
"code": code,
"message": message,
"retryable": retryable,
},
"request_id": request_id or uuid.uuid4().hex,
"data_timestamp": datetime.now(UTC).isoformat(),
}
if suggested_fix:
env["error"]["suggested_fix"] = suggested_fix
if details:
env["error"]["details"] = details
return env
class _TimestampInjectorMiddleware(BaseHTTPMiddleware):
"""CER-P5-001: inietta data_timestamp nei response tool.
- Dict response: body gains `data_timestamp` se mancante.
- List of dicts: ogni item gains `data_timestamp` se mancante.
- Header `X-Data-Timestamp` sempre presente (universale per list primitive).
Skips /health (già popolato) e /mcp (JSON-RPC bridge) e non-JSON responses.
"""
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
path = request.url.path
if not path.startswith("/tools/"):
return response
ctype = response.headers.get("content-type", "")
if "application/json" not in ctype:
return response
body = b""
async for chunk in response.body_iterator:
body += chunk
ts = datetime.now(UTC).isoformat()
try:
data = json.loads(body) if body else None
except Exception:
headers = dict(response.headers)
headers["X-Data-Timestamp"] = ts
return Response(
content=body,
status_code=response.status_code,
headers=headers,
media_type=response.media_type,
)
modified = False
if isinstance(data, dict) and "data_timestamp" not in data:
data["data_timestamp"] = ts
modified = True
elif isinstance(data, list):
for item in data:
if isinstance(item, dict) and "data_timestamp" not in item:
item["data_timestamp"] = ts
modified = True
headers = dict(response.headers)
headers["X-Data-Timestamp"] = ts
if modified:
new_body = json.dumps(data, default=str).encode()
headers.pop("content-length", None)
return Response(
content=new_body,
status_code=response.status_code,
headers=headers,
media_type="application/json",
)
return Response(
content=body,
status_code=response.status_code,
headers=headers,
media_type=response.media_type,
)
def build_app(
*,
name: str,
version: str,
token_store: TokenStore,
lifespan: Lifespan | None = None,
) -> FastAPI:
root_path = os.getenv("ROOT_PATH", "")
app = FastAPI(title=name, version=version, root_path=root_path, lifespan=lifespan)
app.state.token_store = token_store
app.state.boot_at = time.time()
app.add_middleware(_TimestampInjectorMiddleware)
@app.middleware("http")
async def _latency_header(request: Request, call_next):
t0 = time.perf_counter()
response = await call_next(request)
dur_ms = (time.perf_counter() - t0) * 1000
response.headers["X-Duration-Ms"] = f"{dur_ms:.2f}"
return response
# CER-P5-002 error envelope: exception handlers globali
@app.exception_handler(HTTPException)
async def _http_exc(request: Request, exc: HTTPException):
retryable = exc.status_code in (408, 429, 502, 503, 504)
code_map = {
400: "BAD_REQUEST", 401: "UNAUTHORIZED", 403: "FORBIDDEN",
404: "NOT_FOUND", 408: "TIMEOUT", 409: "CONFLICT",
422: "VALIDATION_ERROR", 429: "RATE_LIMIT",
500: "INTERNAL_ERROR", 502: "UPSTREAM_ERROR",
503: "UNAVAILABLE", 504: "GATEWAY_TIMEOUT",
}
code = code_map.get(exc.status_code, f"HTTP_{exc.status_code}")
message = "HTTP error"
details: dict | None = None
detail = exc.detail
# Preserve rail-style detail {"error": "..", "message": ".."} as code
if isinstance(detail, dict):
if isinstance(detail.get("error"), str):
code = detail["error"].upper()
message = str(detail.get("message") or detail.get("error") or message)
details = detail
elif isinstance(detail, str):
message = detail
return JSONResponse(
status_code=exc.status_code,
content=_error_envelope(
type_="http_error",
code=code,
message=message,
retryable=retryable,
details=details,
),
)
@app.exception_handler(RequestValidationError)
async def _validation_exc(request: Request, exc: RequestValidationError):
errs = exc.errors()
first_loc = ".".join(str(x) for x in errs[0]["loc"]) if errs else "body"
suggestion = (
f"check field '{first_loc}': "
+ (errs[0]["msg"] if errs else "invalid input")
)
# Sanitize ctx values: pydantic v2 può mettere ValueError in ctx['error'],
# non serializzabile JSON. Riduci a stringhe.
safe_errs: list[dict] = []
for e in errs[:5]:
ne: dict = {}
for k, v in e.items():
if k == "ctx" and isinstance(v, dict):
ne[k] = {ck: str(cv) for ck, cv in v.items()}
else:
ne[k] = v
safe_errs.append(ne)
return JSONResponse(
status_code=422,
content=_error_envelope(
type_="validation_error",
code="INVALID_INPUT",
message=f"request body validation failed on {first_loc}",
retryable=False,
suggested_fix=suggestion,
details={"errors": safe_errs},
),
)
@app.exception_handler(Exception)
async def _unhandled(request: Request, exc: Exception):
return JSONResponse(
status_code=500,
content=_error_envelope(
type_="internal_error",
code="UNHANDLED_EXCEPTION",
message=f"{type(exc).__name__}: {str(exc)[:300]}",
retryable=True,
),
)
@app.get("/health")
def health():
return {
"status": "healthy",
"name": name,
"version": version,
"uptime_seconds": int(time.time() - app.state.boot_at),
"data_timestamp": datetime.now(UTC).isoformat(),
}
return app