# -*- 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 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", "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: return JSONResponse( {"error": f"MCP-Protocol-Version non supportata: {header_version}"}, status_code=400 ) 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.get("/mcp") def mcp_get(): return JSONResponse( {"error": "Use POST for MCP JSON-RPC"}, status_code=405, headers=_auth_challenge_headers(), ) 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.

"""