mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-22 20:52:23 +02:00
340 lines
15 KiB
Python
340 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
"""Stdio MCP server exposing ShowUI: render a rich inline component in the chat transcript.
|
|
|
|
Display-only. The frontend renders the component straight from the tool_call input it already
|
|
has in the transcript, so this server just validates the payload and acknowledges; there is no
|
|
backend round-trip and nothing here can mutate state.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
BACKEND_PORT = os.environ.get("OPENSWARM_PORT", "8324")
|
|
BACKEND_AUTH = os.environ.get("OPENSWARM_AUTH_TOKEN", "")
|
|
PARENT_SESSION_ID = os.environ.get("OPENSWARM_PARENT_SESSION_ID", "")
|
|
WAIT_URL = f"http://127.0.0.1:{BACKEND_PORT}/api/ui-requests/wait"
|
|
ASK_TIMEOUT_S = 600
|
|
|
|
MAX_PROPS_BYTES = 20_000
|
|
|
|
# Hints + JSON Schemas for the vendored tool-ui set are GENERATED from the shipped zod contracts
|
|
# (frontend/scripts/gen-toolui-hints.ts writes toolui_schemas.json next to this file). Loading them
|
|
# here means the tool description and the server-side validation can never drift from what renders.
|
|
def p_load_generated():
|
|
try:
|
|
with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "toolui_schemas.json")) as f:
|
|
return json.load(f)
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
GENERATED = p_load_generated()
|
|
|
|
COMPONENT_SPECS = {
|
|
"weather": "props: {id?: str, location: str, temp: number, unit?: 'F'|'C', high?: number, low?: number, condition?: str, forecast?: [{day: str, condition?: str, high?: number, low?: number}] (max 7)}",
|
|
"stats": "props: {title?: str, stats: [{label: str, value: str, delta?: str, direction?: 'up'|'down'}] (max 8)}",
|
|
"links": "props: {links: [{title: str, url: str, description?: str}] (max 10)}",
|
|
}
|
|
|
|
COMPONENT_SPECS.update({name: entry["hint"] for name, entry in GENERATED.items()})
|
|
|
|
|
|
INTERACTIVE_COMPONENTS = (
|
|
"option-list", "question-flow", "parameter-slider", "preferences-panel", "approval-card",
|
|
"message-draft",
|
|
)
|
|
|
|
TOOLS = [
|
|
{
|
|
"name": "AskUI",
|
|
"description": (
|
|
"Render an INTERACTIVE component in the chat and WAIT for the user's answer (up to 10 "
|
|
"minutes); the tool result is their response. Use this instead of plain-text questions "
|
|
"when the choice fits a component. Components: "
|
|
+ ", ".join(f"'{name}'" for name in INTERACTIVE_COMPONENTS)
|
|
+ ". Props follow the same shapes as ShowUI (props.id is REQUIRED, it correlates the "
|
|
"answer). The response contains the action taken and the user's selection/values. "
|
|
"The user may also answer in their own words instead of picking an option; then the "
|
|
"result is {action: 'free_text', value: {text}}, treat that text as their answer."
|
|
),
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"component": {
|
|
"type": "string",
|
|
"enum": list(INTERACTIVE_COMPONENTS),
|
|
"description": "Which interactive component to render.",
|
|
},
|
|
"props": {
|
|
"type": "object",
|
|
"description": "Data for the component; must include a stable string id.",
|
|
},
|
|
},
|
|
"required": ["component", "props"],
|
|
},
|
|
},
|
|
{
|
|
"name": "ShowUI",
|
|
"description": (
|
|
"Render a rich inline UI component in the chat instead of describing data as text. "
|
|
"Components are the DEFAULT presentation; plain prose is for content with no structure "
|
|
"or media. Any image URL you mention renders via image/image-gallery (remote URLs load "
|
|
"straight from the internet), video via video, sound via audio, never a bare link. "
|
|
"Components: "
|
|
+ ", ".join(COMPONENT_SPECS.keys())
|
|
+ ". Call it with the component name and a props object; if your props are off, the tool "
|
|
"returns that component's exact required shape so you can fix and re-call. The component "
|
|
"renders in place of raw text; still give a one-line text summary after. "
|
|
"LIVE UPDATES: calling ShowUI again with the SAME component and props.id updates that "
|
|
"card in place. Use this to advance progress-tracker/plan step statuses AS you complete "
|
|
"each step of real work, or to refresh data; never mint a new id for an update. Before "
|
|
"ending your turn, send a final same-id update with truthful terminal statuses; never "
|
|
"leave a step marked in-progress for work you are not actually doing."
|
|
),
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"component": {
|
|
"type": "string",
|
|
"enum": list(COMPONENT_SPECS.keys()),
|
|
"description": "Which component to render.",
|
|
},
|
|
"props": {
|
|
"type": "object",
|
|
"description": "Data for the component, matching its documented shape.",
|
|
},
|
|
},
|
|
"required": ["component", "props"],
|
|
},
|
|
},
|
|
]
|
|
|
|
|
|
def send_response(id_, result=None, error=None):
|
|
msg = {"jsonrpc": "2.0", "id": id_}
|
|
if error is not None:
|
|
msg["error"] = error
|
|
else:
|
|
msg["result"] = result
|
|
sys.stdout.write(json.dumps(msg) + "\n")
|
|
sys.stdout.flush()
|
|
|
|
|
|
def validate(component: str, props: dict) -> str:
|
|
if component not in COMPONENT_SPECS:
|
|
return f"Unknown component {component!r}. Supported: {', '.join(COMPONENT_SPECS)}."
|
|
try:
|
|
size = len(json.dumps(props))
|
|
except (TypeError, ValueError):
|
|
return "props must be JSON-serializable."
|
|
if size > MAX_PROPS_BYTES:
|
|
return f"props too large ({size} bytes; max {MAX_PROPS_BYTES})."
|
|
if component == "weather" and not (isinstance(props.get("location"), str) and isinstance(props.get("temp"), (int, float))):
|
|
return f"weather needs at least location + temp. {COMPONENT_SPECS['weather']}"
|
|
if component == "stats" and not (isinstance(props.get("stats"), list) and props["stats"]):
|
|
return f"stats needs a non-empty stats list. {COMPONENT_SPECS['stats']}"
|
|
if component == "links" and not (isinstance(props.get("links"), list) and props["links"]):
|
|
return f"links needs a non-empty links list. {COMPONENT_SPECS['links']}"
|
|
# Vendored components: validate against the GENERATED JSON Schema so a bad payload comes back
|
|
# as a teaching error the model can fix in-turn, instead of a dead render it never hears about.
|
|
# jsonschema gives full-constraint parity with the client zod gate (minimum/minLength/minItems
|
|
# slipped through the hand walker: question-flow step>=1 rendered server-side, died client-side).
|
|
entry = GENERATED.get(component)
|
|
if entry and isinstance(entry.get("schema"), dict):
|
|
errors = p_full_validate(props, entry["schema"])
|
|
if errors is None:
|
|
errors = []
|
|
p_check(props, entry["schema"], "props", errors)
|
|
if errors:
|
|
return (
|
|
f"{component} payload invalid: " + "; ".join(errors[:4])
|
|
+ f". Full shape: {COMPONENT_SPECS[component]}. Fix the props and call the tool again."
|
|
)
|
|
return ""
|
|
|
|
|
|
def p_full_validate(props: dict, schema: dict):
|
|
"""Full JSON Schema validation via jsonschema; None = library unavailable (fallback walker runs)."""
|
|
try:
|
|
import jsonschema
|
|
except ImportError:
|
|
return None
|
|
try:
|
|
validator = jsonschema.Draft202012Validator(schema)
|
|
out = []
|
|
for err in sorted(validator.iter_errors(props), key=lambda e: len(e.path)):
|
|
where = "props" + "".join(f".{p}" if isinstance(p, str) else f"[{p}]" for p in err.path)
|
|
out.append(f"{where}: {err.message[:90]}")
|
|
if len(out) >= 6:
|
|
break
|
|
return out
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def p_type_ok(value, t: str) -> bool:
|
|
if t == "string":
|
|
return isinstance(value, str)
|
|
if t in ("number", "integer"):
|
|
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
if t == "boolean":
|
|
return isinstance(value, bool)
|
|
if t == "object":
|
|
return isinstance(value, dict)
|
|
if t == "array":
|
|
return isinstance(value, list)
|
|
if t == "null":
|
|
return value is None
|
|
return True
|
|
|
|
|
|
def p_check(value, schema: dict, path: str, errors: list) -> None:
|
|
"""Minimal JSON Schema walk: required keys, primitive types, enums, anyOf. Anything it can't
|
|
interpret passes; the client zod contract stays the deep authority."""
|
|
if len(errors) >= 6 or not isinstance(schema, dict):
|
|
return
|
|
branches = schema.get("anyOf")
|
|
if isinstance(branches, list) and branches:
|
|
for branch in branches:
|
|
trial = []
|
|
p_check(value, branch, path, trial)
|
|
if not trial:
|
|
return
|
|
errors.append(f"{path} matches none of its allowed shapes")
|
|
return
|
|
enum = schema.get("enum")
|
|
if isinstance(enum, list) and enum and value not in enum:
|
|
errors.append(f"{path} must be one of {enum[:6]}")
|
|
return
|
|
t = schema.get("type")
|
|
if isinstance(t, str) and not p_type_ok(value, t):
|
|
errors.append(f"{path} must be a {t}")
|
|
return
|
|
if t == "object" and isinstance(value, dict):
|
|
for key in schema.get("required", []) or []:
|
|
if key not in value:
|
|
errors.append(f"{path}.{key} is required")
|
|
props = schema.get("properties") or {}
|
|
for key, sub in props.items():
|
|
if key in value:
|
|
p_check(value[key], sub, f"{path}.{key}", errors)
|
|
elif t == "array" and isinstance(value, list):
|
|
items = schema.get("items")
|
|
if isinstance(items, dict):
|
|
for i, item in enumerate(value):
|
|
p_check(item, items, f"{path}[{i}]", errors)
|
|
|
|
|
|
def p_post(url: str, body: dict, timeout: float) -> dict:
|
|
payload = json.dumps(body).encode()
|
|
headers = {"Content-Type": "application/json"}
|
|
if BACKEND_AUTH:
|
|
headers["Authorization"] = f"Bearer {BACKEND_AUTH}"
|
|
req = urllib.request.Request(url, data=payload, headers=headers, method="POST")
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
return json.loads(resp.read().decode())
|
|
except urllib.error.HTTPError as e:
|
|
body_txt = e.read().decode(errors="replace") if e.fp else str(e)
|
|
return {"error": f"HTTP {e.code}: {body_txt[:300]}"}
|
|
except Exception as e:
|
|
return {"error": str(e)}
|
|
|
|
|
|
def handle_ask_ui(arguments: dict) -> dict:
|
|
component = str(arguments.get("component", "")).strip()
|
|
props = arguments.get("props")
|
|
# Bounces return isError False on purpose: the CLI never fires PostToolUse for errored calls, so an isError bounce leaves no tool_result and the frontend renders the dead ask as a live clickable card forever (ENG-232).
|
|
if not isinstance(props, dict):
|
|
return {"content": [{"type": "text", "text": "props must be an object."}]}
|
|
if component not in INTERACTIVE_COMPONENTS:
|
|
return {"content": [{"type": "text", "text": f"AskUI only supports: {', '.join(INTERACTIVE_COMPONENTS)}. Use ShowUI for display-only components."}]}
|
|
# Registered RAW, not stripped: the frontend responds with the untrimmed id, and a mismatched key means the answer never lands.
|
|
component_id = str(props.get("id", ""))
|
|
if not component_id.strip():
|
|
return {"content": [{"type": "text", "text": "props.id (a stable string) is required so the answer can be correlated."}]}
|
|
problem = validate(component, props)
|
|
if problem:
|
|
return {"content": [{"type": "text", "text": f"Not rendered: {problem}"}]}
|
|
r = p_post(WAIT_URL, {"session_id": PARENT_SESSION_ID, "component_id": component_id, "timeout_s": ASK_TIMEOUT_S}, timeout=ASK_TIMEOUT_S + 20)
|
|
if "error" in r:
|
|
return {"content": [{"type": "text", "text": f"AskUI failed: {r['error']}"}]}
|
|
if not r.get("ok"):
|
|
return {"content": [{"type": "text", "text": "The user didn't respond within 10 minutes. Continue without their input or ask again."}]}
|
|
return {"content": [{"type": "text", "text": json.dumps(r.get("response"))}]}
|
|
|
|
|
|
def handle_tool_call(tool_name: str, arguments: dict) -> dict:
|
|
if tool_name == "AskUI":
|
|
return handle_ask_ui(arguments)
|
|
if tool_name != "ShowUI":
|
|
return {"content": [{"type": "text", "text": f"Unknown tool: {tool_name}"}], "isError": True}
|
|
component = str(arguments.get("component", "")).strip()
|
|
props = arguments.get("props")
|
|
# Same isError-False rule as AskUI: a bounce that never persists leaves a phantom pending row in the transcript.
|
|
if not isinstance(props, dict):
|
|
return {"content": [{"type": "text", "text": "props must be an object."}]}
|
|
# Every vendored component's schema already demands an id; stats and links (ours) did not, so an
|
|
# id-less re-call minted a duplicate card instead of updating in place (ENG-232 P2). One rule for all.
|
|
if not str(props.get("id", "")).strip():
|
|
return {"content": [{"type": "text", "text": (
|
|
"props.id (a stable string) is required. Re-call with the SAME id to update this card in "
|
|
"place; a new id makes a new card."
|
|
)}]}
|
|
# A display-only render wires no click handlers, so an interactive component via ShowUI draws dead buttons (message-draft even animates a send that sent nothing); teach instead of rendering a lie.
|
|
if component in INTERACTIVE_COMPONENTS:
|
|
return {"content": [{"type": "text", "text": (
|
|
f"Not rendered: '{component}' is interactive and ShowUI is display-only, so its buttons "
|
|
"would be dead. Call AskUI with the same component and props (props.id required) to "
|
|
"collect the user's answer."
|
|
)}]}
|
|
problem = validate(component, props)
|
|
if problem:
|
|
return {"content": [{"type": "text", "text": f"Not rendered: {problem}"}]}
|
|
return {"content": [{"type": "text", "text": f"Rendered a '{component}' component inline."}]}
|
|
|
|
|
|
def main():
|
|
for line in sys.stdin:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
msg = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
|
|
method = msg.get("method")
|
|
id_ = msg.get("id")
|
|
params = msg.get("params", {}) or {}
|
|
|
|
if method == "initialize":
|
|
send_response(id_, {
|
|
"protocolVersion": "2024-11-05",
|
|
"capabilities": {"tools": {}},
|
|
"serverInfo": {
|
|
"name": "openswarm-ui",
|
|
"version": "1.0.0",
|
|
},
|
|
})
|
|
elif method == "notifications/initialized":
|
|
pass
|
|
elif method == "tools/list":
|
|
send_response(id_, {"tools": TOOLS})
|
|
elif method == "tools/call":
|
|
tool_name = params.get("name", "")
|
|
arguments = params.get("arguments", {}) or {}
|
|
result = handle_tool_call(tool_name, arguments)
|
|
send_response(id_, result)
|
|
elif method == "ping":
|
|
send_response(id_, {})
|
|
elif id_ is not None:
|
|
send_response(id_, error={"code": -32601, "message": f"Method not found: {method}"})
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|