369 lines
12 KiB
Python
369 lines
12 KiB
Python
# -*- 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'<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>"""
|