Files

450 lines
15 KiB
Python

"""
Quant-UX MCP Server — exposes the Quant-UX prototype tool to any MCP client
(Claude Code, Codex CLI, Cursor, ...) over Streamable HTTP.
Environment variables:
QUX_BASE_URL Quant-UX frontend base URL (default http://127.0.0.1:8082)
QUX_ADMIN_EMAIL optional account used to auto-login at startup
QUX_ADMIN_PASSWORD optional password for the account above
MCP_API_KEY optional Bearer key required on every HTTP request
(strongly recommended when exposed on a public server)
MCP_PORT HTTP port (default 8090)
Run: python -m uvicorn server:app --host 0.0.0.0 --port 8090
"""
import json
import os
from mcp.server.mcpserver import MCPServer
from quantux_client import QuantUXClient, QuantUXError
from quantux_auth import DownloadTokenStore, QuantUXSessionManager
from quantux_export import QuantUXExporter
from quantux_verify import verify_html
BASE_URL = os.environ.get("QUX_BASE_URL", "http://127.0.0.1:8082")
ADMIN_EMAIL = os.environ.get("QUX_ADMIN_EMAIL", "")
ADMIN_PASSWORD = os.environ.get("QUX_ADMIN_PASSWORD", "")
EXPORT_DIR = os.environ.get("QUX_EXPORT_DIR", "/app/exports")
os.makedirs(EXPORT_DIR, exist_ok=True)
_client = QuantUXClient(BASE_URL)
_session = QuantUXSessionManager(_client, ADMIN_EMAIL, ADMIN_PASSWORD)
_client.set_auth_refresh_handler(_session.refresh_after_auth_failure)
def _auto_login():
"""Log in at startup if admin credentials are configured."""
if ADMIN_EMAIL and ADMIN_PASSWORD:
try:
_session.ensure_authenticated(force=True)
print(f"Auto-login ok as {ADMIN_EMAIL}", flush=True)
except Exception as exc: # noqa: BLE001
print(f"WARNING: auto-login failed: {exc}", flush=True)
_auto_login()
def _client_or_error():
return _session.ensure_authenticated()
def _ok(data):
if isinstance(data, (dict, list)):
return json.dumps(data, ensure_ascii=False)
return str(data)
server = MCPServer(
"quantux",
title="Quant-UX Design Server",
description=(
"Create, edit and wire interactive UI prototypes on a self-hosted "
"Quant-UX instance. Tools cover accounts, apps, screens, widgets "
"(Box/Label/Button/TextBox/Password/Image/...), styles and flow "
"connections."
),
version="1.0.0",
)
# ---------------------------------------------------------------- accounts
@server.tool(
name="quantux_login",
title="Login",
description="Log into Quant-UX with an email/password. Required before any "
"other tool if the server has no admin account configured.",
)
def quantux_login(email: str, password: str) -> str:
"""Authenticate against Quant-UX and cache the JWT."""
user = _client.login(email, password)
return _ok({
"status": "ok",
"email": user.get("email"),
"name": user.get("name"),
"role": user.get("role"),
})
@server.tool(
name="quantux_register",
title="Register account",
description="Create a new Quant-UX user account.",
)
def quantux_register(name: str, lastname: str, email: str, password: str) -> str:
user = _client.register(name, lastname, email, password)
return _ok({"status": "ok", "id": user.get("_id"), "email": user.get("email")})
# ------------------------------------------------------------------- apps
@server.tool(
name="quantux_list_apps",
title="List apps",
description="List all prototypes/apps owned by the logged-in user.",
)
def quantux_list_apps() -> str:
apps = _client_or_error().list_apps()
summary = [{
"id": a.get("_id") or a.get("id"),
"name": a.get("name"),
"type": a.get("type"),
"screenSize": a.get("screenSize"),
"isPublic": a.get("isPublic", False),
} for a in apps]
return _ok(summary)
@server.tool(
name="quantux_get_app",
title="Inspect app",
description="Return a structured summary of an app: screens, widget counts "
"and flow lines. Use quantux_dump_app for the raw model.",
)
def quantux_get_app(app_id: str) -> str:
return _ok(_client_or_error().describe(app_id))
@server.tool(
name="quantux_dump_app",
title="Dump raw app model",
description="Return the full raw Quant-UX model JSON of an app "
"(screens/widgets/lines with all styles).",
)
def quantux_dump_app(app_id: str) -> str:
return _ok(_client_or_error().get_app(app_id))
@server.tool(
name="quantux_create_app",
title="Create app",
description="Create a new prototype. Defaults to a 375x667 smartphone "
"canvas; pass width/height for desktop (e.g. 1280x720).",
)
def quantux_create_app(
name: str,
description: str = "",
width: int = 375,
height: int = 667,
app_type: str = "prototype",
) -> str:
app_id = _client_or_error().create_app(
name, description=description, width=width, height=height,
app_type=app_type,
)
return _ok({"status": "ok", "app_id": app_id, "name": name})
@server.tool(
name="quantux_delete_app",
title="Delete app",
description="Permanently delete an app.",
)
def quantux_delete_app(app_id: str) -> str:
_client_or_error().delete_app(app_id)
return _ok({"status": "ok", "deleted": app_id})
# ---------------------------------------------------------------- screens
@server.tool(
name="quantux_add_screen",
title="Add screen",
description="Add a screen to an app. The first screen automatically "
"becomes the start screen.",
)
def quantux_add_screen(app_id: str, name: str, width: int = 0, height: int = 0) -> str:
c = _client_or_error()
sid = c.add_screen(
app_id, name,
width=width or None, height=height or None,
)
return _ok({"status": "ok", "screen_id": sid, "name": name})
# ---------------------------------------------------------------- widgets
@server.tool(
name="quantux_add_widget",
title="Add widget",
description=(
"Add a widget to a screen. widget_type is one of: Box (rectangle), "
"Label (text), Button, TextBox (input), Password, TextArea, Image, "
"Icon, HotSpot. Coordinates x/y are absolute within the screen; "
"w/h are width/height. Style keys are CSS-ish: background, color, "
"fontSize, fontWeight, textAlign, borderRadius corners, border*Width, "
"padding*, boxShadow, lineHeight, letterSpacing, fontFamily."
),
)
def quantux_add_widget(
app_id: str,
screen_id: str,
widget_type: str,
x: int,
y: int,
w: int,
h: int,
name: str = "",
props_json: str = "{}",
style_json: str = "{}",
) -> str:
props = json.loads(props_json or "{}")
style = json.loads(style_json or "{}")
wid = _client_or_error().add_widget(
app_id, screen_id, widget_type, x, y, w, h,
name=name or None, props=props, style=style,
)
return _ok({"status": "ok", "widget_id": wid, "type": widget_type})
@server.tool(
name="quantux_update_widget",
title="Update widget",
description="Patch a widget: style_json/props_json are merged onto the "
"existing values; x/y/w/h/name replace position/size/name.",
)
def quantux_update_widget(
app_id: str,
widget_id: str,
style_json: str = "{}",
props_json: str = "{}",
x: int = -1,
y: int = -1,
w: int = -1,
h: int = -1,
name: str = "",
) -> str:
c = _client_or_error()
c.update_widget(
app_id, widget_id,
props=json.loads(props_json or "{}") or None,
style=json.loads(style_json or "{}") or None,
x=x if x >= 0 else None,
y=y if y >= 0 else None,
w=w if w >= 0 else None,
h=h if h >= 0 else None,
name=name or None,
)
return _ok({"status": "ok", "widget_id": widget_id})
@server.tool(
name="quantux_delete_widget",
title="Delete widget",
description="Remove a widget from its screen.",
)
def quantux_delete_widget(app_id: str, widget_id: str) -> str:
_client_or_error().delete_widget(app_id, widget_id)
return _ok({"status": "ok", "deleted": widget_id})
# ------------------------------------------------------------------- flows
@server.tool(
name="quantux_connect_flow",
title="Connect flow",
description=(
"Wire an interaction: clicking the 'from' widget navigates to the "
"'to' screen (or widget). event defaults to 'click'."
),
)
def quantux_connect_flow(
app_id: str,
from_widget_id: str,
to_screen_id: str,
event: str = "click",
) -> str:
_client_or_error().connect_flow(app_id, from_widget_id, to_screen_id, event)
return _ok({"status": "ok", "from": from_widget_id,
"to": to_screen_id, "event": event})
# ------------------------------------------------------------- escape hatch
@server.tool(
name="quantux_apply_changes",
title="Apply raw changes",
description=(
"Low-level escape hatch: apply a raw Quant-UX delta array to an app. "
"Each change: {\"type\":\"add|update|delete\", \"name\":<id or field>, "
"\"parent\":\"screens|widgets|lines|groups|null\", \"object\":<value>}. "
"Use for advanced edits not covered by the other tools."
),
)
def quantux_apply_changes(app_id: str, changes_json: str) -> str:
changes = json.loads(changes_json)
_client_or_error().apply_changes(app_id, changes)
return _ok({"status": "ok", "applied": len(changes)})
# ------------------------------------------------------------------- health
@server.tool(
name="quantux_export_html",
title="Export interactive HTML",
description=(
"Export a prototype to a single self-contained interactive HTML file "
"that works fully offline (open in any browser, click through flows). "
"Images are embedded as base64. The export is automatically verified "
"for interaction consistency; the 'verify' field reports the checks. "
"The result contains a short-lived download URL scoped to that file "
"(or pull from the server's ~/quantux-mcp/exports/ directory)."
),
)
def quantux_export_html(app_id: str) -> str:
c = _client_or_error()
path = QuantUXExporter(c).export(app_id, out_path=os.path.join(EXPORT_DIR, f"{app_id}.html"))
try:
with open(path, encoding="utf-8") as fh:
verify = verify_html(fh.read())
except Exception as exc: # noqa: BLE001
verify = {"status": "error", "error": str(exc)[:200]}
filename = os.path.basename(path)
download_token = _download_tokens.issue(filename)
return _ok({
"status": "ok",
"app_id": app_id,
"file": filename,
"bytes": os.path.getsize(path),
"download": f"/exports/{filename}?download_token={download_token}",
"download_expires_in_seconds": _download_tokens.ttl_seconds,
"verify": {
"verdict": verify.get("verdict"),
"widgetCount": verify.get("widgetCount"),
"wiredCount": verify.get("wiredCount"),
"navigation": verify.get("navigation"),
"inputsEditable": verify.get("inputsEditable"),
"toggleWorks": verify.get("toggleWorks"),
"charts": verify.get("charts", 0),
"tables": verify.get("tables", 0),
"repeater": None,
"animation": verify.get("animation"),
"runtimeErrors": len(verify.get("runtimeErrors") or []),
"unsupportedTypes": list((verify.get("model") or {}).get("unsupported", {}).keys()),
},
})
@server.tool(
name="quantux_verify_export",
title="Verify export consistency",
description=(
"Export a prototype and run the full consistency verification "
"(render, wiring, navigation, inputs, toggles, charts/tables, "
"animation, widget-type coverage). Returns the detailed report."
),
)
def quantux_verify_export(app_id: str) -> str:
c = _client_or_error()
path = QuantUXExporter(c).export(app_id, out_path=os.path.join(EXPORT_DIR, f"{app_id}.html"))
with open(path, encoding="utf-8") as fh:
report = verify_html(fh.read())
report["file"] = os.path.basename(path)
return _ok(report)
@server.tool(
name="quantux_health",
title="Health check",
description="Check MCP server status and Quant-UX backend reachability.",
)
def quantux_health() -> str:
status = {"mcp": "ok", "quantux_base": BASE_URL}
auth_error = None
try:
if ADMIN_EMAIL and ADMIN_PASSWORD:
_session.ensure_authenticated()
except Exception as exc: # noqa: BLE001
auth_error = str(exc)[:200]
try:
r = _client._req("GET", "/rest/status.json")
status["quantux"] = "ok"
status["backend"] = r.get("version")
except Exception as exc: # noqa: BLE001
status["quantux"] = f"error: {exc}"
status.update(_session.status())
if auth_error:
status["auth_error"] = auth_error
return _ok(status)
# ------------------------------------------------------------------- app
# Auth middleware: require Bearer MCP_API_KEY on every request.
_API_KEY = os.environ.get("MCP_API_KEY", "")
_download_tokens = DownloadTokenStore(
ttl_seconds=int(os.environ.get("MCP_EXPORT_TOKEN_TTL_SECONDS", "600"))
)
def _auth_required(request):
if not _API_KEY:
return None
auth = request.headers.get("authorization", "")
expected = f"Bearer {_API_KEY}"
if auth != expected:
return {"error": "unauthorized", "detail": "invalid or missing API key"}
return None
def make_app():
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
from starlette.staticfiles import StaticFiles
starlette_app = server.streamable_http_app(
streamable_http_path="/mcp",
# Explicit non-localhost host disables the SDK's automatic DNS
# rebinding protection (which would otherwise 421-reject requests
# whose Host header is not 127.0.0.1). Access control is provided
# by MCP_API_KEY auth plus the cloud security group on the port.
host=os.environ.get("MCP_HOST", "0.0.0.0"),
)
# Serve exported prototypes at /exports/<file>?key=<MCP_API_KEY>
# (GET with the key as a query param so it works in a plain browser).
starlette_app.mount(
"/exports",
StaticFiles(directory=EXPORT_DIR),
name="exports",
)
class AuthMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
problem = _auth_required(request)
if problem:
if request.method == "GET" and request.url.path.startswith("/exports/"):
filename = os.path.basename(request.url.path)
token = request.query_params.get("download_token", "")
if _download_tokens.validate(token, filename):
return await call_next(request)
return JSONResponse(problem, status_code=401)
return await call_next(request)
starlette_app.add_middleware(AuthMiddleware)
return starlette_app
app = make_app()
if __name__ == "__main__":
import uvicorn
port = int(os.environ.get("MCP_PORT", "8090"))
uvicorn.run(app, host="0.0.0.0", port=port)