# -*- coding: utf-8 -*- """Loogle MCP Hub — gateway FastAPI + OAuth + MCP Streamable HTTP.""" import json import logging import os import secrets from typing import Optional 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 . 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") @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) # ------------------------------------------------------------------ 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 oauth.exchange_code( body.get("code", ""), client_id, body.get("redirect_uri", ""), body.get("code_verifier"), ) if grant_type == "refresh_token": return 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 @app.post("/mcp") async def mcp_post(request: Request): auth_header = request.headers.get("authorization", "") claims = oauth.bearer_claims_from_header(auth_header) try: payload = await request.json() except Exception: raise HTTPException(400, "JSON non valido") if isinstance(payload, list): responses = mcp_server.handle_batch(payload, claims) return JSONResponse(responses) response = mcp_server.handle_message(payload, claims) if not claims and payload.get("method") not in ("initialize", "notifications/initialized", "ping"): return JSONResponse(response, status_code=401, headers=_auth_challenge_headers()) return JSONResponse(response) @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"""Accedi con le credenziali famiglia per collegare Claude, ChatGPT o Gemini.
Primo accesso: password = username (es. lucia/lucia). Cambiala dal dashboard.