commit a2d355a276ea02a471a3c15645cb748e522423e5 Author: QiuSW <105186638@qq.com> Date: Mon Aug 17 10:24:19 2026 +0800 1,initial diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a40c6b1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.12-slim + +WORKDIR /app + +# PyPI is blocked on this China-based host; use the Tsinghua mirror. +COPY requirements.txt ./ +RUN pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt + +COPY quantux_client.py server.py ./ + +ENV QUX_BASE_URL=http://quant-ux-frontend:8082 \ + MCP_PORT=8090 + +EXPOSE 8090 + +CMD ["python", "-m", "uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8090"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..40d2473 --- /dev/null +++ b/README.md @@ -0,0 +1,122 @@ +# Quant-UX MCP Server + +Expose a self-hosted [Quant-UX](https://github.com/KlausSchaefers/quant-ux) +prototyping instance to any MCP client (Claude Code, Codex CLI, Cursor, +DeepSeek Harness, ...) over **Streamable HTTP**. + +## 架构 + +``` +┌──────────────────────── 服务器 124.222.27.183 ────────────────────────┐ +│ │ +│ ┌───────────────┐ ┌────────────────┐ ┌──────────────────────┐ │ +│ │ quantux-mcp │──▶│ quant-ux- │──▶│ quant-ux-backend │ │ +│ │ :8091 (MCP) │ │ frontend :8082 │ │ :8080 (Java, REST) │ │ +│ └───────────────┘ └────────────────┘ └──────────────────────┘ │ +│ ▲ quantux_default 网络 │ +└────────┼─────────────────────────────────────────────────────────────┘ + │ HTTPS/HTTP + Bearer API Key + ┌────┴─────┐ ┌─────────┐ ┌───────┐ + │Claude Code│ │ Codex CLI │ │ ...任何 MCP 客户端 │ + └──────────┘ └─────────┘ └───────┘ +``` + +## 已部署 + +| 项目 | 值 | +|---|---| +| MCP 端点 | `http://124.222.27.183:8091/mcp` | +| 传输 | Streamable HTTP(MCP 协议 2025-11-25) | +| 鉴权 | `Authorization: Bearer `(`.env` 中) | +| Quant-UX 账号 | 服务器自动以 `QUX_ADMIN_EMAIL`(当前 ila2002@qq.com)登录,agent 无需关心登录 | +| 容器 | `quantux-mcp`(docker compose,`restart: always`),接入 `quantux_default` 网络 | + +## 工具清单(14 个) + +| 工具 | 说明 | +|---|---| +| `quantux_login` / `quantux_register` | 登录 / 注册 Quant-UX 账号 | +| `quantux_list_apps` / `quantux_get_app` / `quantux_dump_app` | 列出 / 概览 / 原始模型 | +| `quantux_create_app` / `quantux_delete_app` | 创建 / 删除原型(默认 375×667,可设桌面尺寸) | +| `quantux_add_screen` | 添加屏幕(第一个自动成为起始屏) | +| `quantux_add_widget` | 添加组件:Box/Label/Button/TextBox/Password/TextArea/Image/Icon/HotSpot | +| `quantux_update_widget` / `quantux_delete_widget` | 修改样式/位置/文案 / 删除组件 | +| `quantux_connect_flow` | 画交互连线(点击 A 跳转 B) | +| `quantux_apply_changes` | 底层逃生舱:直接提交 raw delta 数组 | +| `quantux_health` | 健康检查 | + +## 客户端接入配置 + +### Claude Code(`claude mcp add` 或项目 `.mcp.json`) + +```bash +claude mcp add quantux \ + --transport http \ + --url http://124.222.27.183:8091/mcp \ + --header "Authorization: Bearer " +``` + +或 `.mcp.json`(项目根目录): + +```json +{ + "mcpServers": { + "quantux": { + "type": "http", + "url": "http://124.222.27.183:8091/mcp", + "headers": { "Authorization": "Bearer " } + } + } +} +``` + +### Codex CLI(`~/.codex/config.toml`) + +```toml +[mcp_servers.quantux] +type = "http" +url = "http://124.222.27.183:8091/mcp" +headers = { "Authorization" = "Bearer " } +``` + +### 任意 MCP 客户端(通用) + +```json +{ + "mcpServers": { + "quantux": { + "type": "http", + "url": "http://124.222.27.183:8091/mcp", + "headers": { "Authorization": "Bearer " } + } + } +} +``` + +> `` 见服务器 `~/quantux-mcp/.env`。 + +## Agent 使用示例 + +给 agent 的自然语言指令: + +> 用 quantux 工具创建一个 375×667 的"购物 App"原型:先建应用,再加一个"商品列表"屏幕,放 3 个商品卡片(Box+Label),底部放一个"购物车"按钮,最后把按钮连到"购物车"屏幕。 + +## 本地开发 / 重新部署 + +```bash +cd ~/quantux-mcp +sudo docker compose up -d --build # 重建并启动 +sudo docker compose logs -f quantux-mcp # 日志 +sudo docker compose down # 停止 +``` + +环境变量(`.env`): +- `MCP_API_KEY`:调用方必须携带的 Bearer 密钥 +- `QUX_ADMIN_EMAIL` / `QUX_ADMIN_PASSWORD`:启动时自动登录的账号 +- `QUX_BASE_URL`:Quant-UX 前端地址(容器内默认 `http://quant-ux-frontend:8082`) + +## 安全提示 + +- MCP 端点暴露在公网时,**务必设置强 `MCP_API_KEY`**,并建议在腾讯云安全组中将 8091 端口的来源限制为可信 IP +- Quant-UX 后端凭据(admin 账号)只在 MCP 服务器内部使用,不会泄露给调用方 + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ec6e062 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +services: + quantux-mcp: + build: . + container_name: quantux-mcp + restart: always + ports: + - "8091:8090" + environment: + - QUX_BASE_URL=http://quant-ux-frontend:8082 + - MCP_API_KEY=${MCP_API_KEY:?set MCP_API_KEY in .env} + - QUX_ADMIN_EMAIL=${QUX_ADMIN_EMAIL:-} + - QUX_ADMIN_PASSWORD=${QUX_ADMIN_PASSWORD:-} + networks: + - quantux_default + +networks: + quantux_default: + external: true diff --git a/quantux_client.py b/quantux_client.py new file mode 100644 index 0000000..b17c13b --- /dev/null +++ b/quantux_client.py @@ -0,0 +1,300 @@ +""" +Quant-UX REST API client — core library shared by the MCP server and CLI. + +Talks directly to the Quant-UX backend through its REST API +(frontend proxies /rest/* to the Java backend). + +Key facts learned from reading qux-java source: + * Auth: POST /rest/user (register), POST /rest/login (returns {"token": }) + subsequent calls: Authorization: Bearer + * Apps: GET /rest/apps, POST /rest/apps, GET /rest/apps/:id.json, DELETE ... + * Changes: POST /rest/apps/:id/update body = JSON array of deltas: + {"type": "add"|"update"|"delete", + "name": , + "parent": "screens"|"widgets"|"lines"|"groups"|"templates"|null, + "object": } + -> translated server-side into mongo $set/$unset + * Pitfall: the backend rejects the payload with HTTP 405 if it does not + start with "[" AND end with "]" (no trailing newline allowed). + requests' json= parameter serializes compact JSON without a + trailing newline, so it is safe. + * Model format: + screens: {"": {id,name,x,y,w,h,z,min,props:{start},style,has,children:[...]}} + widgets: {"": {id,name,type,x,y,w,h,z,props,has,actions,style}} + lines: {"": {id,from,to,event,points}} + model-level: name, description, type, screenSize{w,h}, startScreen, lastUUID, grid + * Widget MUST carry a non-null "style" (frontend ModelFixer deletes widgets + without style). +""" + +import json +import re + +import requests + +REST_BASE = "/rest" # kept for reference; paths below include it explicitly + + +class QuantUXError(Exception): + """Raised for any API-level failure.""" + + +def _default_style(widget_type): + """Sensible defaults per widget type (mirrors what the frontend uses).""" + font = "Helvetica Neue,Helvetica,Arial,sans-serif" + border_zero = { + "borderTopWidth": 0, "borderBottomWidth": 0, + "borderRightWidth": 0, "borderLeftWidth": 0, + "borderTopColor": "#000000", "borderBottomColor": "#000000", + "borderRightColor": "#000000", "borderLeftColor": "#000000", + } + radius_zero = { + "borderTopRightRadius": 0, "borderTopLeftRadius": 0, + "borderBottomRightRadius": 0, "borderBottomLeftRadius": 0, + } + if widget_type == "Box": + return {**border_zero, "background": "#E5E7EB"} + if widget_type == "Label": + return { + "fontSize": 16, "fontFamily": font, "textAlign": "left", + "letterSpacing": 0, "lineHeight": 1.4, "color": "#111827", + "textShadow": None, + } + if widget_type in ("TextBox", "Password", "TextArea"): + return { + **border_zero, **radius_zero, + "borderTopWidth": 1, "borderBottomWidth": 1, + "borderRightWidth": 1, "borderLeftWidth": 1, + "borderTopColor": "#D1D5DB", "borderBottomColor": "#D1D5DB", + "borderRightColor": "#D1D5DB", "borderLeftColor": "#D1D5DB", + "background": "#FFFFFF", "fontSize": 14, "color": "#111827", + "paddingLeft": 12, "paddingRight": 12, + "paddingTop": 0, "paddingBottom": 0, "textShadow": None, + } + if widget_type == "Button": + return { + "fontSize": 14, "fontFamily": font, "textAlign": "center", + "letterSpacing": 0, "lineHeight": 1.4, "color": "#FFFFFF", + **radius_zero, **border_zero, "background": "#111827", + "paddingTop": 0, "paddingBottom": 0, + "paddingLeft": 0, "paddingRight": 0, "textShadow": None, + } + if widget_type == "HotSpot": + return {} + # generic fallback + return {**border_zero, **radius_zero, "background": "#FFFFFF"} + + +def _default_has(widget_type): + if widget_type == "Label": + return {"label": True, "padding": True, "advancedText": True} + if widget_type in ("TextBox", "Password", "TextArea"): + return {"label": True, "border": True, "padding": True, "backgroundColor": True} + if widget_type == "Button": + return {"backgroundColor": True, "border": True, "label": True, + "padding": True, "onclick": True} + if widget_type == "HotSpot": + return {"onclick": True} + return {"backgroundColor": True, "border": True} + + +class QuantUXClient: + """Thin, battle-tested wrapper around the Quant-UX REST API.""" + + def __init__(self, base_url, token=None, timeout=30): + self.base_url = base_url.rstrip("/") + self.timeout = timeout + self.session = requests.Session() + if token: + self.token = token + self.session.headers["Authorization"] = f"Bearer {token}" + else: + self.token = None + + # ------------------------------------------------------------------ auth + def login(self, email, password): + """Login and cache the JWT for all subsequent calls.""" + r = self._req("POST", "/rest/login", + json={"email": email, "password": password}) + token = r.get("token") + if not token: + raise QuantUXError("Login succeeded but no token in response") + self.token = token + self.session.headers["Authorization"] = f"Bearer {token}" + return r + + def register(self, name, lastname, email, password): + return self._req("POST", "/rest/user", json={ + "name": name, "lastname": lastname, + "email": email, "password": password, "tos": True, + }) + + # ------------------------------------------------------------------ apps + def list_apps(self): + return self._req("GET", "/rest/apps") + + def get_app(self, app_id): + return self._req("GET", f"/rest/apps/{app_id}.json") + + def create_app(self, name, description="", width=375, height=667, + app_type="prototype", is_public=False): + r = self._req("POST", "/rest/apps", json={ + "name": name, + "description": description, + "type": app_type, + "screenSize": {"w": width, "h": height}, + "isPublic": is_public, + }) + return r["_id"] + + def delete_app(self, app_id): + return self._req("DELETE", f"/rest/apps/{app_id}.json") + + # ---------------------------------------------------------------- changes + def apply_changes(self, app_id, changes): + """POST a delta array to /rest/apps/:id/update (the only write path).""" + if not isinstance(changes, list): + raise QuantUXError("changes must be a JSON array") + return self._req("POST", f"/rest/apps/{app_id}/update", json=changes) + + def _next_id(self, model): + """Next numeric string id for this app.""" + seen = [] + for coll in ("screens", "widgets", "lines", "groups", "templates"): + seen.extend(int(k) for k in model.get(coll, {}).keys() + if str(k).isdigit()) + base = max(seen, default=10000) + lu = int(model.get("lastUUID") or 10000) + return max(base + 1, lu + 1) + + # --------------------------------------------------------------- screens + def add_screen(self, app_id, name, width=None, height=None): + model = self.get_app(app_id) + w = width or model["screenSize"]["w"] + h = height or model["screenSize"]["h"] + is_first = len(model.get("screens", {})) == 0 + sid = str(self._next_id(model)) + screen = { + "id": sid, "name": name, "x": 0, "y": 0, "w": w, "h": h, "z": 0, + "min": {"h": h, "w": w}, + "props": {"start": is_first}, + "style": {}, "has": {"image": True}, "children": [], + } + changes = [ + {"type": "add", "parent": "screens", "name": sid, "object": screen}, + {"type": "update", "name": "lastUUID", "object": int(sid)}, + ] + if is_first: + changes.append({"type": "update", "name": "startScreen", "object": sid}) + self.apply_changes(app_id, changes) + return sid + + # --------------------------------------------------------------- widgets + def add_widget(self, app_id, screen_id, widget_type, x, y, w, h, + name=None, props=None, has=None, style=None): + model = self.get_app(app_id) + screen = model["screens"].get(screen_id) + if not screen: + raise QuantUXError(f"Screen {screen_id} not found in app {app_id}") + wid = str(self._next_id(model)) + widget = { + "id": wid, + "name": name or widget_type, + "type": widget_type, + "x": x, "y": y, "w": w, "h": h, "z": 0, + "props": props or {}, + "has": has if has is not None else _default_has(widget_type), + "actions": {}, + "style": style if style is not None else _default_style(widget_type), + } + screen["children"] = list(screen.get("children", [])) + [wid] + changes = [ + {"type": "add", "parent": "widgets", "name": wid, "object": widget}, + {"type": "update", "parent": "screens", "name": screen_id, + "object": screen}, + {"type": "update", "name": "lastUUID", "object": int(wid)}, + ] + self.apply_changes(app_id, changes) + return wid + + def update_widget(self, app_id, widget_id, props=None, style=None, + x=None, y=None, w=None, h=None, name=None): + model = self.get_app(app_id) + widget = model["widgets"].get(widget_id) + if not widget: + raise QuantUXError(f"Widget {widget_id} not found in app {app_id}") + if name is not None: + widget["name"] = name + if props: + widget["props"] = {**(widget.get("props") or {}), **props} + if style: + widget["style"] = {**(widget.get("style") or {}), **style} + for key, val in (("x", x), ("y", y), ("w", w), ("h", h)): + if val is not None: + widget[key] = val + return self.apply_changes(app_id, [ + {"type": "update", "parent": "widgets", "name": widget_id, + "object": widget}, + ]) + + def delete_widget(self, app_id, widget_id): + model = self.get_app(app_id) + changes = [{"type": "delete", "parent": "widgets", "name": widget_id}] + for screen in model.get("screens", {}).values(): + if widget_id in screen.get("children", []): + screen["children"] = [c for c in screen["children"] + if c != widget_id] + changes.append({"type": "update", "parent": "screens", + "name": screen["id"], "object": screen}) + return self.apply_changes(app_id, changes) + + # ------------------------------------------------------------------ lines + def connect_flow(self, app_id, from_id, to_id, event="click"): + """Wire an interaction: clicking 'from' navigates to 'to'.""" + model = self.get_app(app_id) + lid = str(self._next_id(model)) + line = {"id": lid, "from": from_id, "to": to_id, + "event": event, "points": []} + return self.apply_changes(app_id, [ + {"type": "add", "parent": "lines", "name": lid, "object": line}, + {"type": "update", "name": "lastUUID", "object": int(lid)}, + ]) + + # ------------------------------------------------------------------ misc + def describe(self, app_id): + """Human/agent friendly summary of an app model.""" + m = self.get_app(app_id) + out = { + "id": m.get("_id") or m.get("id"), + "name": m.get("name"), + "type": m.get("type"), + "screenSize": m.get("screenSize"), + "startScreen": m.get("startScreen"), + "screens": [], + "widgets": len(m.get("widgets", {})), + "lines": len(m.get("lines", {})), + } + for sid, s in (m.get("screens") or {}).items(): + out["screens"].append({ + "id": s.get("id"), "name": s.get("name"), + "w": s.get("w"), "h": s.get("h"), + "start": (s.get("props") or {}).get("start", False), + "widgetCount": len(s.get("children") or []), + }) + return out + + # ------------------------------------------------------------- transport + def _req(self, method, path, **kw): + url = self.base_url + path + kw.setdefault("timeout", self.timeout) + try: + resp = self.session.request(method, url, **kw) + except requests.RequestException as exc: + raise QuantUXError(f"Request to {url} failed: {exc}") from exc + if resp.status_code >= 400: + body = resp.text[:500] + raise QuantUXError(f"HTTP {resp.status_code} from {method} {path}: {body}") + try: + return resp.json() + except ValueError: + return resp.text diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5e00984 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +mcp>=2.0.0 +requests>=2.31 +uvicorn>=0.29 +starlette>=0.37 diff --git a/server.py b/server.py new file mode 100644 index 0000000..977051e --- /dev/null +++ b/server.py @@ -0,0 +1,358 @@ +""" +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 + +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", "") + +_client = QuantUXClient(BASE_URL) + + +def _auto_login(): + """Log in at startup if admin credentials are configured.""" + if ADMIN_EMAIL and ADMIN_PASSWORD: + try: + _client.login(ADMIN_EMAIL, ADMIN_PASSWORD) + 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(): + if not _client.token: + raise QuantUXError( + "Not logged in. Call quantux_login(email, password) first " + "(or configure QUX_ADMIN_EMAIL/QUX_ADMIN_PASSWORD on the server)." + ) + return _client + + +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\":, " + "\"parent\":\"screens|widgets|lines|groups|null\", \"object\":}. " + "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_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} + 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["logged_in"] = bool(_client.token) + return _ok(status) + + +# ------------------------------------------------------------------- app +# Auth middleware: require Bearer MCP_API_KEY on every request. +_API_KEY = os.environ.get("MCP_API_KEY", "") + + +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 + + 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"), + ) + + class AuthMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request, call_next): + problem = _auth_required(request) + if problem: + 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) diff --git a/tests/mcp_client_test.py b/tests/mcp_client_test.py new file mode 100644 index 0000000..b77f266 --- /dev/null +++ b/tests/mcp_client_test.py @@ -0,0 +1,37 @@ +import asyncio +import os + + +def main(): + KEY = open("/home/ubuntu/quantux-mcp/.env").read().split("MCP_API_KEY=")[1].splitlines()[0].strip() + URL = os.environ.get("MCP_URL", "http://127.0.0.1:8091/mcp") + + import httpx2 + from mcp import ClientSession + from mcp.client.streamable_http import streamable_http_client + + async def run(): + http_client = httpx2.AsyncClient( + headers={"Authorization": f"Bearer {KEY}"} + ) + async with streamable_http_client(URL, http_client=http_client) as (read, write): + async with ClientSession(read, write) as session: + init = await session.initialize() + print("server:", init.server_info.name, init.server_info.version) + print("protocol:", init.protocol_version) + + tools = await session.list_tools() + print("tools count:", len(tools.tools)) + print("tool names:", [t.name for t in tools.tools]) + + r = await session.call_tool("quantux_health", {}) + print("health ->", r.content[0].text) + + r = await session.call_tool("quantux_list_apps", {}) + print("apps ->", r.content[0].text) + + asyncio.run(run()) + + +if __name__ == "__main__": + main() diff --git a/tests/mcp_create_flow_test.py b/tests/mcp_create_flow_test.py new file mode 100644 index 0000000..59049d4 --- /dev/null +++ b/tests/mcp_create_flow_test.py @@ -0,0 +1,64 @@ +import asyncio +import json +import os + + +def main(): + KEY = open("/home/ubuntu/quantux-mcp/.env").read().split("MCP_API_KEY=")[1].splitlines()[0].strip() + URL = os.environ.get("MCP_URL", "http://127.0.0.1:8091/mcp") + + import httpx2 + from mcp import ClientSession + from mcp.client.streamable_http import streamable_http_client + + async def call(session, name, args): + r = await session.call_tool(name, args) + return r.content[0].text + + async def run(): + http_client = httpx2.AsyncClient(headers={"Authorization": f"Bearer {KEY}"}) + async with streamable_http_client(URL, http_client=http_client) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + + # 1. create app + app = json.loads(await call(session, "quantux_create_app", { + "name": "MCP创建的应用", "description": "通过 MCP 工具链自动创建", "width": 390, "height": 844})) + app_id = app["app_id"] + print("1. create_app ->", app_id) + + # 2. add screen + scr = json.loads(await call(session, "quantux_add_screen", {"app_id": app_id, "name": "欢迎页"})) + screen_id = scr["screen_id"] + print("2. add_screen ->", screen_id) + + # 3. add widgets + lbl = json.loads(await call(session, "quantux_add_widget", { + "app_id": app_id, "screen_id": screen_id, "widget_type": "Label", + "x": 40, "y": 120, "w": 310, "h": 50, "name": "标题", + "style_json": json.dumps({"fontSize": 30, "fontWeight": 700, "color": "#1F2937", "textAlign": "center"})})) + btn = json.loads(await call(session, "quantux_add_widget", { + "app_id": app_id, "screen_id": screen_id, "widget_type": "Button", + "x": 60, "y": 400, "w": 270, "h": 52, "name": "开始按钮", + "props_json": json.dumps({"label": "开始使用"}), + "style_json": json.dumps({"background": "#10B981", "color": "#FFFFFF", "fontSize": 16, + "borderTopLeftRadius": 26, "borderTopRightRadius": 26, + "borderBottomLeftRadius": 26, "borderBottomRightRadius": 26})})) + print("3. add_widgets ->", lbl["widget_id"], btn["widget_id"]) + + # 4. connect flow: button -> (self, demo only) create second screen and wire + scr2 = json.loads(await call(session, "quantux_add_screen", {"app_id": app_id, "name": "首页"})) + screen2 = scr2["screen_id"] + await call(session, "quantux_connect_flow", + {"app_id": app_id, "from_widget_id": btn["widget_id"], "to_screen_id": screen2}) + print("4. connect_flow ->", btn["widget_id"], "->", screen2) + + # 5. verify + desc = json.loads(await call(session, "quantux_get_app", {"app_id": app_id})) + print("5. get_app ->", json.dumps(desc, ensure_ascii=False)) + + asyncio.run(run()) + + +if __name__ == "__main__": + main()