1,initial
This commit is contained in:
+16
@@ -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"]
|
||||
@@ -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 服务器内部使用,不会泄露给调用方
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
mcp>=2.0.0
|
||||
requests>=2.31
|
||||
uvicorn>=0.29
|
||||
starlette>=0.37
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user