1,initial

This commit is contained in:
QiuSW
2026-08-17 10:24:19 +08:00
commit a2d355a276
8 changed files with 919 additions and 0 deletions
+16
View File
@@ -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"]
+122
View File
@@ -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 <MCP_API_KEY>`(`.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_API_KEY>"
```
或 `.mcp.json`(项目根目录):
```json
{
"mcpServers": {
"quantux": {
"type": "http",
"url": "http://124.222.27.183:8091/mcp",
"headers": { "Authorization": "Bearer <MCP_API_KEY>" }
}
}
}
```
### Codex CLI(`~/.codex/config.toml`)
```toml
[mcp_servers.quantux]
type = "http"
url = "http://124.222.27.183:8091/mcp"
headers = { "Authorization" = "Bearer <MCP_API_KEY>" }
```
### 任意 MCP 客户端(通用)
```json
{
"mcpServers": {
"quantux": {
"type": "http",
"url": "http://124.222.27.183:8091/mcp",
"headers": { "Authorization": "Bearer <MCP_API_KEY>" }
}
}
}
```
> `<MCP_API_KEY>` 见服务器 `~/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 服务器内部使用,不会泄露给调用方
+18
View File
@@ -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
+300
View File
@@ -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": <JWT>})
subsequent calls: Authorization: Bearer <JWT>
* 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": <field or id>,
"parent": "screens"|"widgets"|"lines"|"groups"|"templates"|null,
"object": <value>}
-> 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>": {id,name,x,y,w,h,z,min,props:{start},style,has,children:[...]}}
widgets: {"<id>": {id,name,type,x,y,w,h,z,props,has,actions,style}}
lines: {"<id>": {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
+4
View File
@@ -0,0 +1,4 @@
mcp>=2.0.0
requests>=2.31
uvicorn>=0.29
starlette>=0.37
+358
View File
@@ -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\":<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_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)
+37
View File
@@ -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()
+64
View File
@@ -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()