Files
loogle-scripts/services/loogle-mcp/app/main.py
T

504 lines
18 KiB
Python

# -*- 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'<p class="error">{error}</p>' if error else ""
return f"""<!DOCTYPE html>
<html lang="it"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Loogle MCP — Login</title>
<style>
body{{font-family:system-ui,sans-serif;max-width:420px;margin:4rem auto;padding:1rem;background:#0f172a;color:#e2e8f0}}
h1{{font-size:1.4rem}} .card{{background:#1e293b;padding:1.5rem;border-radius:12px}}
label{{display:block;margin:.75rem 0 .25rem}} input{{width:100%;padding:.5rem;border-radius:6px;border:1px solid #334155;background:#0f172a;color:#e2e8f0}}
button{{margin-top:1rem;width:100%;padding:.65rem;background:#2563eb;color:#fff;border:none;border-radius:8px;font-size:1rem;cursor:pointer}}
.error{{color:#f87171}} .hint{{font-size:.85rem;color:#94a3b8;margin-top:1rem}}
</style></head><body>
<h1>Loogle MCP Hub</h1>
<p>Accedi con le credenziali famiglia per collegare Claude, ChatGPT o Gemini.</p>
<div class="card">{err}
<form method="post" action="/authorize">
<input type="hidden" name="client_id" value="{client_id}">
<input type="hidden" name="redirect_uri" value="{redirect_uri}">
<input type="hidden" name="scope" value="{scope}">
<input type="hidden" name="state" value="{state}">
<input type="hidden" name="code_challenge" value="{code_challenge or ''}">
<input type="hidden" name="code_challenge_method" value="{code_challenge_method or ''}">
<label>Utente</label><input name="username" autocomplete="username" required>
<label>Password</label><input name="password" type="password" autocomplete="current-password" required>
<button type="submit">Autorizza accesso MCP</button>
</form>
<p class="hint">Primo accesso: password = username (es. lucia/lucia). Cambiala dal dashboard.</p>
</div></body></html>"""