# -*- coding: utf-8 -*- """Loogle MCP Hub — gateway FastAPI + OAuth + MCP Streamable HTTP.""" import asyncio import json import logging import os import secrets from typing import Optional import anyio from fastapi import Depends, FastAPI, Form, HTTPException, Request, Response from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, StreamingResponse from pydantic import BaseModel from starlette.concurrency import run_in_threadpool from . import audit, auth, jwt_utils, oauth from .db import get_conn, init_db from .mcp import server as mcp_server logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s") LOGGER = logging.getLogger("loogle_mcp.main") app = FastAPI(title="Loogle MCP Hub", docs_url=None, redoc_url=None) app.add_middleware( CORSMiddleware, allow_origins=[ "https://claude.ai", "https://chatgpt.com", "https://chat.openai.com", ], allow_methods=["GET", "POST", "HEAD", "OPTIONS"], allow_headers=["*"], ) STATIC_DIR = os.path.join(os.path.dirname(__file__), "static") # Thread per tool MCP ed endpoint sync: i tool fanno I/O bloccante (HTTP, SQLite, Ollama) MCP_THREAD_LIMIT = int(os.environ.get("MCP_THREAD_LIMIT", "40")) # Sotto il timeout dei client MCP: meglio un errore chiaro che una connessione appesa MCP_TOOL_TIMEOUT_S = float(os.environ.get("MCP_TOOL_TIMEOUT_S", "45")) @app.on_event("startup") def on_startup() -> None: init_db() auth.ensure_sessions_table() auth.ensure_family_users() oauth.ensure_default_client() if not os.environ.get("MCP_JWT_SECRET", "").strip(): secret = secrets.token_urlsafe(48) os.environ["MCP_JWT_SECRET"] = secret LOGGER.warning("MCP_JWT_SECRET generato — salvalo in .env: %s", secret) @app.on_event("startup") async def configure_threadpool() -> None: anyio.to_thread.current_default_thread_limiter().total_tokens = MCP_THREAD_LIMIT # ------------------------------------------------------------------ Health @app.get("/health") def health(): return {"ok": True, "service": "loogle-mcp", "port": int(os.environ.get("MCP_PORT", "8700"))} # ------------------------------------------------------------------ OAuth metadata @app.get("/.well-known/oauth-authorization-server") def oauth_metadata(): return oauth.authorization_server_metadata() @app.get("/.well-known/oauth-protected-resource") def protected_resource(): return oauth.protected_resource_metadata() @app.get("/.well-known/oauth-protected-resource/mcp") def protected_resource_mcp(): return oauth.protected_resource_metadata() @app.get("/.well-known/openid-configuration") def openid_configuration(): """Fallback discovery usato da Claude se oauth-authorization-server non basta.""" return oauth.authorization_server_metadata() # ------------------------------------------------------------------ OAuth endpoints class RegisterBody(BaseModel): client_name: str redirect_uris: list[str] @app.post("/oauth/register") def oauth_register(body: RegisterBody): try: return oauth.register_client(body.client_name, body.redirect_uris) except HTTPException as exc: return JSONResponse( status_code=exc.status_code, content={"error": "invalid_client_metadata", "error_description": str(exc.detail)}, ) # Alias root-level OAuth (Claude/ChatGPT fallback se la discovery RFC 8414 fallisce) @app.post("/register") def oauth_register_root(body: RegisterBody): return oauth_register(body) @app.get("/oauth/authorize") def oauth_authorize_get( response_type: str, client_id: str, redirect_uri: str, scope: str = "context:read context:write knowledge:read knowledge:write gitea:read gitea:write", state: str = "", code_challenge: Optional[str] = None, code_challenge_method: Optional[str] = None, ): if response_type != "code": raise HTTPException(400, "response_type must be code") html = _login_form(client_id, redirect_uri, scope, state, code_challenge, code_challenge_method) return HTMLResponse(html) @app.post("/oauth/authorize") def oauth_authorize_post( request: Request, client_id: str = Form(...), redirect_uri: str = Form(...), scope: str = Form("context:read context:write knowledge:read knowledge:write gitea:read gitea:write"), state: str = Form(""), code_challenge: Optional[str] = Form(None), code_challenge_method: Optional[str] = Form(None), username: str = Form(...), password: str = Form(...), ): ip = request.client.host if request.client else "?" auth.throttle(ip) user = auth.authenticate(username, password) if not user: auth.record_attempt(ip) html = _login_form( client_id, redirect_uri, scope, state, code_challenge, code_challenge_method, error="Credenziali non valide", ) return HTMLResponse(html, status_code=401) url = oauth.build_authorize_redirect( client_id, redirect_uri, scope, state, code_challenge, code_challenge_method, user["id"] ) return RedirectResponse(url, status_code=302) @app.get("/authorize") def oauth_authorize_get_root( response_type: str, client_id: str, redirect_uri: str, scope: str = "context:read context:write knowledge:read knowledge:write gitea:read gitea:write", state: str = "", code_challenge: Optional[str] = None, code_challenge_method: Optional[str] = None, ): return oauth_authorize_get( response_type, client_id, redirect_uri, scope, state, code_challenge, code_challenge_method ) @app.post("/authorize") def oauth_authorize_post_root( request: Request, client_id: str = Form(...), redirect_uri: str = Form(...), scope: str = Form("context:read context:write knowledge:read knowledge:write gitea:read gitea:write"), state: str = Form(""), code_challenge: Optional[str] = Form(None), code_challenge_method: Optional[str] = Form(None), username: str = Form(...), password: str = Form(...), ): return oauth_authorize_post( request, client_id, redirect_uri, scope, state, code_challenge, code_challenge_method, username, password ) @app.post("/oauth/token") async def oauth_token(request: Request): content_type = request.headers.get("content-type", "") if "application/json" in content_type: body = await request.json() else: form = await request.form() body = dict(form) grant_type = body.get("grant_type") client_id = body.get("client_id") or os.environ.get("MCP_OAUTH_CLIENT_ID", "loogle-mcp-public") if grant_type == "authorization_code": return await run_in_threadpool( oauth.exchange_code, body.get("code", ""), client_id, body.get("redirect_uri", ""), body.get("code_verifier"), ) if grant_type == "refresh_token": return await run_in_threadpool( oauth.refresh_access_token, body.get("refresh_token", ""), client_id ) raise HTTPException(400, "grant_type non supportato") @app.post("/token") async def oauth_token_root(request: Request): return await oauth_token(request) # ------------------------------------------------------------------ MCP endpoint _UNAUTHENTICATED_METHODS = ("initialize", "notifications/initialized", "ping") @app.post("/mcp") async def mcp_post(request: Request): header_version = request.headers.get("mcp-protocol-version") if header_version and header_version not in mcp_server.SUPPORTED_PROTOCOL_VERSIONS: # Non bloccare: Claude/Anthropic mandano versioni nuove; negoziamo nel body initialize LOGGER.warning( "MCP-Protocol-Version header=%s non in lista locale — continuo (negoziazione JSON-RPC)", header_version, ) auth_header = request.headers.get("authorization", "") claims = await run_in_threadpool(oauth.bearer_claims_from_header, auth_header) try: payload = await request.json() except Exception: raise HTTPException(400, "JSON non valido") messages = payload if isinstance(payload, list) else [payload] if not all(isinstance(msg, dict) for msg in messages) or not messages: return JSONResponse( {"jsonrpc": "2.0", "id": None, "error": {"code": -32600, "message": "Invalid Request"}}, status_code=400, ) if not any(mcp_server.expects_response(msg) for msg in messages): # Solo notifiche/response dal client: Streamable HTTP prevede 202 senza corpo if not claims and any(msg.get("method") not in _UNAUTHENTICATED_METHODS for msg in messages): return Response(status_code=401, headers=_auth_challenge_headers()) return Response(status_code=202) if isinstance(payload, list): responses = await _run_with_timeout(mcp_server.handle_batch, payload, claims) if responses is None: responses = [_timeout_error(msg) for msg in payload if mcp_server.expects_response(msg)] return JSONResponse(responses) response = await _run_with_timeout(mcp_server.handle_message, payload, claims) if response is None: response = _timeout_error(payload) if not claims and payload.get("method") not in _UNAUTHENTICATED_METHODS: return JSONResponse(response, status_code=401, headers=_auth_challenge_headers()) return JSONResponse(response) async def _run_with_timeout(fn, payload, claims): """I tool sono sincroni e bloccanti: threadpool per non fermare l'event loop. Allo scadere ritorna None; il thread prosegue fino alla fine ma il client è già servito. """ try: return await asyncio.wait_for(run_in_threadpool(fn, payload, claims), MCP_TOOL_TIMEOUT_S) except asyncio.TimeoutError: return None def _timeout_error(msg: dict) -> dict: params = msg.get("params") or {} target = params.get("name") or msg.get("method") LOGGER.warning("MCP %s oltre %.0fs: risposta di timeout al client", target, MCP_TOOL_TIMEOUT_S) return { "jsonrpc": "2.0", "id": msg.get("id"), "error": { "code": -32000, "message": f"Timeout: '{target}' non ha risposto entro {MCP_TOOL_TIMEOUT_S:.0f}s " "(backend lento o non raggiungibile). Riprova tra poco.", }, } @app.post("/") async def root_mcp_post(request: Request): """Alcuni client (Claude) dopo OAuth POST-ano sulla root invece che su /mcp.""" ctype = (request.headers.get("content-type") or "").lower() accept = (request.headers.get("accept") or "").lower() if "json" not in ctype and "json" not in accept and "event-stream" not in accept: raise HTTPException(404, "Not Found") return await mcp_post(request) @app.api_route("/mcp", methods=["GET", "HEAD"]) async def mcp_get(request: Request): """Streamable HTTP: GET può aprire SSE; HEAD serve alla discovery di Claude.""" if request.method == "HEAD": return Response( status_code=200, headers={ **_auth_challenge_headers(), "Allow": "GET, HEAD, POST, OPTIONS", "Content-Type": "application/json", }, ) accept = (request.headers.get("accept") or "").lower() if "text/event-stream" in accept: # Nessun messaggio server-initiated: stream SSE vuoto che si chiude subito async def _empty(): yield ": loogle-mcp ready\n\n" return StreamingResponse( _empty(), media_type="text/event-stream", headers={ **_auth_challenge_headers(), "Cache-Control": "no-cache", "Connection": "keep-alive", }, ) return JSONResponse( { "ok": True, "service": "loogle-mcp", "transport": "streamable-http", "endpoint": "/mcp", "hint": "Usa POST JSON-RPC (initialize, tools/list, tools/call)", }, headers=_auth_challenge_headers(), ) @app.api_route("/mcp/", methods=["GET", "HEAD", "POST", "OPTIONS"]) async def mcp_slash(request: Request): """Evita redirect HTTP su /mcp/ (NPM) che rompe Claude.""" if request.method == "POST": return await mcp_post(request) if request.method == "OPTIONS": return Response( status_code=204, headers={ "Allow": "GET, HEAD, POST, OPTIONS", "Access-Control-Allow-Methods": "GET, HEAD, POST, OPTIONS", "Access-Control-Allow-Headers": "Authorization, Content-Type, Accept, MCP-Protocol-Version, Mcp-Session-Id", }, ) return await mcp_get(request) @app.options("/mcp") async def mcp_options(): return Response( status_code=204, headers={ "Allow": "GET, HEAD, POST, OPTIONS", "Access-Control-Allow-Methods": "GET, HEAD, POST, OPTIONS", "Access-Control-Allow-Headers": "Authorization, Content-Type, Accept, MCP-Protocol-Version, Mcp-Session-Id", }, ) def _auth_challenge_headers() -> dict: base = jwt_utils.base_url() resource_metadata = f"{base}/.well-known/oauth-protected-resource/mcp" return { "WWW-Authenticate": f'Bearer realm="mcp", resource_metadata="{resource_metadata}"', } # ------------------------------------------------------------------ Dashboard web class LoginBody(BaseModel): username: str password: str class PasswordBody(BaseModel): old_password: str new_password: str class RevokeBody(BaseModel): refresh_token: str @app.post("/api/login") def api_login(body: LoginBody, request: Request, response: Response): ip = request.client.host if request.client else "?" auth.throttle(ip) user = auth.authenticate(body.username.strip(), body.password) if not user: auth.record_attempt(ip) raise HTTPException(401, "Credenziali non valide") import datetime token = secrets.token_urlsafe(32) expires = ( datetime.datetime.utcnow() + datetime.timedelta(days=auth.SESSION_DAYS) ).strftime("%Y-%m-%d %H:%M:%S") get_conn().execute( "INSERT INTO sessions(token,user_id,expires_at) VALUES (?,?,?)", (token, user["id"], expires), ) get_conn().commit() response.set_cookie("mcp_session", token, max_age=auth.SESSION_DAYS * 86400, httponly=True, samesite="lax", path="/") return {"ok": True, "user": {"username": user["username"], "is_admin": user["is_admin"]}} @app.post("/api/logout") def api_logout(request: Request, response: Response): token = request.cookies.get("mcp_session", "") if token: get_conn().execute("DELETE FROM sessions WHERE token=?", (token,)) get_conn().commit() response.delete_cookie("mcp_session", path="/") return {"ok": True} @app.get("/api/me") def api_me(user=Depends(auth.current_user_from_cookie)): return user @app.post("/api/password") def api_password(body: PasswordBody, user=Depends(auth.current_user_from_cookie)): if len(body.new_password.strip()) < 6: raise HTTPException(400, "La nuova password deve avere almeno 6 caratteri") if not auth.change_password(user["id"], body.old_password, body.new_password): raise HTTPException(400, "Password attuale errata") return {"ok": True} @app.get("/api/projects") def api_projects(user=Depends(auth.current_user_from_cookie)): from .context import store as context_store return context_store.list_projects(user["username"]) @app.get("/api/audit") def api_audit(limit: int = 100, user=Depends(auth.current_user_from_cookie)): if user["is_admin"]: return audit.list_audit(limit=min(limit, 500)) return audit.list_audit(limit=min(limit, 200), username=user["username"]) @app.post("/api/admin/revoke-refresh") def api_revoke_refresh(body: RevokeBody, _=Depends(auth.require_admin)): jwt_utils.revoke_refresh_token(body.refresh_token) return {"ok": True} @app.get("/dashboard") def dashboard_page(): path = os.path.join(STATIC_DIR, "dashboard.html") return HTMLResponse(open(path, encoding="utf-8").read()) @app.get("/") def root(): return RedirectResponse("/dashboard") def _login_form( client_id: str, redirect_uri: str, scope: str, state: str, code_challenge: Optional[str], code_challenge_method: Optional[str], error: str = "", ) -> str: err = f'

{error}

' if error else "" return f""" Loogle MCP — Login

Loogle MCP Hub

Accedi con le credenziali famiglia per collegare Claude, ChatGPT o Gemini.

{err}

Primo accesso: password = username (es. lucia/lucia). Cambiala dal dashboard.

"""