diff --git a/.features/skill/MEMORY.md b/.features/skill/MEMORY.md
index a85a34c..15a0d69 100644
--- a/.features/skill/MEMORY.md
+++ b/.features/skill/MEMORY.md
@@ -2,7 +2,8 @@
> 负责范围:技能包管理服务 - 核心实现
-> 最后更新:2026-04-20
+
+> 最后更新:2026-05-20
## 当前状态
@@ -10,17 +11,23 @@ Skill 系统支持两种来源:官方 skills (`./skills/`) 和用户 skills (`
目前已新增一批**纯 `SKILL.md` 型业务 skill MVP**,用于研究、摘要、报告和情报编排,底层文件处理与外部检索能力继续复用既有 skill。
+MCP UI 类 skill 已按 MCP Apps 模式改造:工具返回数据,静态 HTML App 由 host 加载后通过 postMessage 接收数据渲染。
+
+目前已新增一批**纯 `SKILL.md` 型业务 skill MVP**,用于研究、摘要、报告和情报编排,底层文件处理与外部检索能力继续复用既有 skill。
+
## 核心文件
- `routes/skill_manager.py` - Skill 上传/删除/列表 API
- `agent/plugin_hook_loader.py` - Hook 系统实现
- `agent/deep_assistant.py` - `CustomSkillsMiddleware`
- `agent/prompt_loader.py` - PrePrompt hooks + MCP 配置合并
+- `routes/mcp_resources.py` - MCP App 静态 HTML resource REST 入口
- `skills/` - 官方 skills 目录
- `skills_developing/` - 开发中 skills
## 最近重要事项
+- 2026-05-20: `mcp-ui` 和 `data-dashboard` 改为 MCP Apps 标准模式,App HTML 放在 skill 的 `apps/` 目录,由 host 加载后 postMessage 数据
- 2026-04-20: 为 `rag-retrieve` 新增 `retrieval-policy-forbidden-self-knowledge.md`,禁止知识问答场景使用模型自身知识补全答案,要求严格基于检索证据作答
- 2026-04-19: 环境变量 `SKILLS_SUBDIR` 重命名为 `PROJECT_NAME`,用于选择 `skills/{PROJECT_NAME}` 和 `skills/autoload/{PROJECT_NAME}` 目录
- 2026-04-19: `create_robot_project` 的 autoload 去重和 stale 清理补强,autoload 目录也纳入 managed 清理,避免 `rag-retrieve-only` 场景下旧的 `rag-retrieve` 残留
@@ -32,12 +39,17 @@ Skill 系统支持两种来源:官方 skills (`./skills/`) 和用户 skills (`
## Gotchas(开发必读)
+- ⚠️ MCP App resource REST 读取路径是 `projects/robot/{bot_id}/skills/{server_name}/apps/{resource_name}.html`,前端 bot_id 应由 `ChatView` 从当前 bot 传给 `ChatMessage`,不要在子组件里重新调用 `useBotManager()`
+- ⚠️ `langchain-mcp-adapters` 会丢失 `EmbeddedResource` 的 uri/_meta;MCP App payload 需作为 text JSON 传递给前端识别
+
+
- ⚠️ 纯 `SKILL.md` 型业务 skill 适合先承载 workflow、输入模板、输出模板;需要稳定文件产出或自动化时再补 `scripts/`
- ⚠️ 新业务 skill 应复用既有基础能力 skill(如 `baidu-search`、`xlsx`、`docx`、`pdf`、`schedule-job`、`imap-smtp-email`),避免重复定义底层工具能力
- ⚠️ 新增脚本优先采用 `Python + argparse + JSON stdout`,比 `argv[1] JSON` 更适合自动化链路
- ⚠️ `auto-daily-summary` 需要特别注意中文分句、action 边界截断、risk 窗口裁剪,否则容易把整句/整段吞进去
- ⚠️ `competitor-news-intel` 的 payload 校验应按命令拆分(collect/analyze/run),不要共用一套最小校验
- ⚠️ `competitor-news-intel` 的 `collect/run` 依赖 `BAIDU_API_KEY`;无该环境变量时应返回稳定错误 JSON,不要静默降级
+
- ⚠️ `create_robot_project` 的 autoload 去重是“包含匹配”,只要传入的 skill 字符串里包含 autoload skill 名,就不会重复自动加载
- ⚠️ `_extract_skills_to_robot` 只会从 `skills/{PROJECT_NAME}` 读取官方 skills,默认是 `common`
diff --git a/.features/skill/changelog/2026-Q2.md b/.features/skill/changelog/2026-Q2.md
index 2218b12..83c2148 100644
--- a/.features/skill/changelog/2026-Q2.md
+++ b/.features/skill/changelog/2026-Q2.md
@@ -1,5 +1,11 @@
# 2026-Q2 Skill Changelog
+### 2026-05-20
+- **变更**: `mcp-ui` 和 `data-dashboard` 从自定义 `uri + data` 工具协议改为 MCP Apps 模式
+- **说明**: 静态 HTML App 放在各 skill 的 `apps/` 目录,host 通过 resource URI 加载 iframe,再用 postMessage 传递工具数据
+- **修复**: 前端 MCP App resource 请求改为由 `ChatView` 向 `ChatMessage` 传入当前 botId,避免子组件重新创建 bot manager 导致 bot_id 为空
+- **作者**: Claude
+
### 2026-04-20
- **新增**: `skills/autoload/onprem/rag-retrieve/hooks/retrieval-policy-forbidden-self-knowledge.md`
- **说明**: 基于现有 `retrieval-policy.md` 衍生出更严格的检索策略,明确禁止在知识问答场景中使用模型自身知识补全答案,要求回答只能来自检索证据
diff --git a/fastapi_app.py b/fastapi_app.py
index c35de80..4107d28 100644
--- a/fastapi_app.py
+++ b/fastapi_app.py
@@ -80,8 +80,9 @@ from utils.log_util.logger import init_with_fastapi
# Initialize logger
logger = logging.getLogger('app')
-# Import route modules
+
from routes import chat, files, projects, system, skill_manager, database, memory, bot_manager, knowledge_base, payment, voice
+from routes.mcp_resources import router as mcp_resources_router
from routes.webdav import wsgidav_app
@@ -226,6 +227,7 @@ app.include_router(bot_manager.router)
app.include_router(payment.router)
app.include_router(memory.router)
+
# 注册语音对话路由
app.include_router(voice.router)
@@ -235,8 +237,11 @@ app.include_router(file_manager_router)
# 注册知识库API路由
app.include_router(knowledge_base.router, prefix="/api/v1/knowledge-base", tags=["knowledge-base"])
+
# 挂载 WsgiDAV(WSGI 应用通过 WSGIMiddleware 集成到 ASGI)
+# MCP App resources endpoint
+app.include_router(mcp_resources_router)
# Register the file management API routes
app.include_router(file_manager_router)
diff --git a/pyproject.toml b/pyproject.toml
index 3ed0f11..f64e66b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -47,7 +47,6 @@ dependencies = [
"daytona-sdk",
"langchain-daytona",
"langfuse (>=2.0.0,<4.0.0)",
- "mcp-ui-server (>=1.0.0,<2.0.0)",
]
[tool.poetry.requires-plugins]
diff --git a/routes/chat.py b/routes/chat.py
index ae0344c..9f98e47 100644
--- a/routes/chat.py
+++ b/routes/chat.py
@@ -143,11 +143,11 @@ async def enhanced_generate_stream_response(
elif isinstance(msg, ToolMessage) and msg.content:
message_tag = "TOOL_RESPONSE"
waiting_for_answer_first_char = False
- # Always output UIResource responses even when tool_response is disabled
+ # Always output MCP App responses even when tool_response is disabled
is_ui_resource = (
msg.text
and msg.text.lstrip().startswith('{"')
- and '"ui://' in msg.text
+ and '"type":"app"' in msg.text
)
if config.tool_response or is_ui_resource:
new_content = f"[{message_tag}] {msg.name}\n{msg.text}\n"
diff --git a/routes/mcp_resources.py b/routes/mcp_resources.py
new file mode 100644
index 0000000..1377ddc
--- /dev/null
+++ b/routes/mcp_resources.py
@@ -0,0 +1,77 @@
+"""
+REST endpoint for serving MCP App resources.
+
+Maps ui:// URIs to static HTML App files under the bot's skill directory.
+
+GET /api/v1/mcp/resources?uri=ui://data-dashboard/chart&bot_id=xxx
+ -> projects/robot/{bot_id}/skills/data-dashboard/apps/chart.html
+"""
+
+import logging
+from pathlib import Path
+
+from fastapi import APIRouter, Query, HTTPException
+from fastapi.responses import HTMLResponse
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/api/v1/mcp", tags=["mcp-resources"])
+
+PROJECT_ROOT = Path(__file__).parent.parent
+
+# In-memory cache: (bot_id, uri) -> html string
+_resource_cache: dict[tuple[str, str], str] = {}
+
+
+def _resolve_uri_to_path(uri: str, bot_id: str) -> Path:
+ """Resolve a ui:// URI to a local file path.
+
+ URI format: ui://{server-name}/{resource-name}
+ File path: projects/robot/{bot_id}/skills/{server-name}/apps/{resource-name}.html
+ """
+ if not uri.startswith("ui://"):
+ raise ValueError(f"Invalid URI scheme: {uri}")
+
+ rest = uri[5:] # Remove "ui://"
+ parts = rest.split("/", 1)
+ if len(parts) != 2 or not parts[0] or not parts[1]:
+ raise ValueError(f"Invalid URI format: {uri}")
+
+ server_name, resource_name = parts
+
+ # Prevent path traversal
+ if ".." in server_name or "/" in server_name:
+ raise ValueError(f"Invalid server name: {server_name}")
+ safe_name = Path(resource_name).name
+ if safe_name != resource_name:
+ raise ValueError(f"Invalid resource name: {resource_name}")
+
+ return PROJECT_ROOT / "projects" / "robot" / bot_id / "skills" / server_name / "apps" / f"{resource_name}.html"
+
+
+@router.get("/resources")
+async def get_resource(
+ uri: str = Query(..., description="Resource URI (e.g. ui://data-dashboard/chart)"),
+ bot_id: str = Query(..., description="Bot ID"),
+):
+ """Fetch an MCP App HTML resource by URI and bot_id.
+
+ Returns the static HTML App that should be loaded into an iframe.
+ The host sends tool result data to the iframe via postMessage.
+ """
+ cache_key = (bot_id, uri)
+ if cache_key in _resource_cache:
+ return HTMLResponse(content=_resource_cache[cache_key], media_type="text/html")
+
+ try:
+ file_path = _resolve_uri_to_path(uri, bot_id)
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+
+ if not file_path.is_file():
+ raise HTTPException(status_code=404, detail=f"Resource not found: {uri} (bot_id={bot_id})")
+
+ html = file_path.read_text(encoding="utf-8")
+ _resource_cache[cache_key] = html
+
+ return HTMLResponse(content=html, media_type="text/html")
diff --git a/skills/common/data-dashboard/apps/chart.html b/skills/common/data-dashboard/apps/chart.html
new file mode 100644
index 0000000..648f9f1
--- /dev/null
+++ b/skills/common/data-dashboard/apps/chart.html
@@ -0,0 +1,148 @@
+
+
+
+
+
+Chart
+
+
+
+
+
+
+
+
diff --git a/skills/common/data-dashboard/apps/metrics.html b/skills/common/data-dashboard/apps/metrics.html
new file mode 100644
index 0000000..c556fde
--- /dev/null
+++ b/skills/common/data-dashboard/apps/metrics.html
@@ -0,0 +1,60 @@
+
+
+
+
+
+Metrics
+
+
+
+
+
+
+
+
diff --git a/skills/common/data-dashboard/apps/multi-chart.html b/skills/common/data-dashboard/apps/multi-chart.html
new file mode 100644
index 0000000..3b04985
--- /dev/null
+++ b/skills/common/data-dashboard/apps/multi-chart.html
@@ -0,0 +1,178 @@
+
+
+
+
+
+Multi Chart
+
+
+
+
+
+
+
+
+
diff --git a/skills/common/data-dashboard/dashboard_server.py b/skills/common/data-dashboard/dashboard_server.py
index 1768cc1..7473088 100644
--- a/skills/common/data-dashboard/dashboard_server.py
+++ b/skills/common/data-dashboard/dashboard_server.py
@@ -1,458 +1,165 @@
#!/usr/bin/env python3
"""
-Data Dashboard MCP Server - renders metric data as an interactive dashboard.
-Returns UIResource via the mcp-ui protocol so the frontend renders it as an iframe.
+Data Dashboard MCP Server - standard MCP Apps protocol (SEP-1865).
+
+- tools/call returns structured data only (no HTML)
+- resources/read returns static HTML App files
+- Host renders HTML App in iframe, passes tool data via postMessage
"""
import asyncio
import json
-from typing import Any, Dict, List
-
-from mcp_ui_server import create_ui_resource, UIMetadataKey
+import os
+from typing import Any, Dict
from mcp_common import (
create_error_response,
- create_initialize_response,
create_ping_response,
create_tools_list_response,
load_tools_from_json,
handle_mcp_streaming,
)
+RESOURCE_MIME_TYPE = "text/html;profile=mcp-app"
+APPS_DIR = os.path.join(os.path.dirname(__file__), "apps")
-def _build_dashboard_html(title: str, metrics: List[Dict[str, str]]) -> str:
- """Build a self-contained HTML dashboard from metrics data."""
- cards_html = ""
- for m in metrics:
- label = _esc(m.get("label", ""))
- value = _esc(m.get("value", ""))
- change = m.get("change", "")
- change_type = m.get("change_type", "neutral")
+# Resource URI -> static HTML App file mapping
+RESOURCE_MAP = {
+ "ui://data-dashboard/metrics": "metrics.html",
+ "ui://data-dashboard/chart": "chart.html",
+ "ui://data-dashboard/multi-chart": "multi-chart.html",
+}
- change_html = ""
- if change:
- color = "#16a34a" if change_type == "up" else "#dc2626" if change_type == "down" else "#6b7280"
- arrow = "▲" if change_type == "up" else "▼" if change_type == "down" else "—"
- change_html = f'{arrow} {_esc(change)}'
-
- cards_html += f"""
-
-
{label}
-
{value}
- {f'
{change_html}
' if change_html else ''}
-
"""
-
- return f"""
-
-
-
-
-{_esc(title)}
-
-
-
- {_esc(title)}
- {cards_html}
-
-
-"""
+RESOURCE_DEFINITIONS = [
+ {
+ "uri": "ui://data-dashboard/metrics",
+ "name": "metrics-dashboard",
+ "title": "Metrics Dashboard",
+ "description": "Renders KPI metric cards",
+ "mimeType": RESOURCE_MIME_TYPE,
+ },
+ {
+ "uri": "ui://data-dashboard/chart",
+ "name": "chart",
+ "title": "Chart",
+ "description": "Renders a single ECharts chart",
+ "mimeType": RESOURCE_MIME_TYPE,
+ },
+ {
+ "uri": "ui://data-dashboard/multi-chart",
+ "name": "multi-chart",
+ "title": "Multi Chart",
+ "description": "Renders multiple ECharts charts in a grid",
+ "mimeType": RESOURCE_MIME_TYPE,
+ },
+]
-def _esc(text: str) -> str:
- """Minimal HTML escaping."""
- return text.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
+def _load_app_html(uri: str) -> str:
+ """Load static HTML App file for the given resource URI."""
+ filename = RESOURCE_MAP.get(uri)
+ if not filename:
+ raise ValueError(f"Unknown resource URI: {uri}")
+ filepath = os.path.join(APPS_DIR, filename)
+ with open(filepath, "r", encoding="utf-8") as f:
+ return f.read()
-# ---------------------------------------------------------------------------
-# Chart HTML builder (ECharts)
-# ---------------------------------------------------------------------------
+def _create_app_response(resource_uri: str, data: Dict[str, Any],
+ width: str = "100%", height: str = "auto") -> Dict[str, Any]:
+ """Create a tool result for MCP Apps protocol.
-ECHARTS_CDN = "https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"
-
-
-def _build_chart_html(
- title: str,
- chart_type: str,
- data: Dict[str, Any],
- width: str = "100%",
- height: str = "400px",
- theme: str = "light",
- stacked: bool = False,
- smooth: bool = False,
- show_label: bool = False,
-) -> str:
- """Build a self-contained HTML page with an ECharts chart."""
- series = data.get("series", [])
- categories = data.get("categories", [])
-
- option = _build_echarts_option(
- title, chart_type, categories, series,
- stacked=stacked, smooth=smooth, show_label=show_label,
- )
- option_json = json.dumps(option, ensure_ascii=False)
-
- bg_color = "#1a1a2e" if theme == "dark" else "#f8fafc"
- text_color = "#e0e0e0" if theme == "dark" else "#0f172a"
- echarts_theme = "'dark'" if theme == "dark" else "null"
-
- return f"""
-
-
-
-
-{_esc(title)}
-
-
-
-
-
-
-
-"""
-
-
-def _build_echarts_option(
- title: str,
- chart_type: str,
- categories: List,
- series_data: List[Dict[str, Any]],
- stacked: bool = False,
- smooth: bool = False,
- show_label: bool = False,
-) -> Dict[str, Any]:
- """Build an ECharts option dict based on chart_type."""
- builders = {
- "line": _option_line,
- "bar": _option_bar,
- "pie": _option_pie,
- "radar": _option_radar,
- "scatter": _option_scatter,
- "gauge": _option_gauge,
- }
- builder = builders.get(chart_type, _option_line)
- return builder(title, categories, series_data, stacked=stacked, smooth=smooth, show_label=show_label)
-
-
-def _base_option(title: str) -> Dict[str, Any]:
- """Shared base option for all chart types."""
- return {
- "title": {"text": title, "left": "center", "top": 8, "textStyle": {"fontSize": 16, "fontWeight": 600}},
- "tooltip": {"trigger": "axis"},
- "legend": {"top": 36},
- "grid": {"top": 80, "left": 60, "right": 30, "bottom": 40},
- "color": ["#5470c6", "#91cc75", "#fac858", "#ee6666", "#73c0de", "#3ba272", "#fc8452", "#9a60b4"],
- }
-
-
-def _label_cfg(show: bool) -> Dict[str, Any]:
- return {"show": show, "position": "top", "fontSize": 11}
-
-
-def _option_line(title, categories, series_data, stacked=False, smooth=False, show_label=False):
- opt = _base_option(title)
- opt["tooltip"]["trigger"] = "axis"
- opt["xAxis"] = {"type": "category", "data": categories, "boundaryGap": False}
- opt["yAxis"] = {"type": "value"}
- opt["series"] = [
- {
- "name": s["name"],
- "type": "line",
- "data": s["data"],
- "smooth": smooth,
- "stack": "total" if stacked else None,
- "areaStyle": {} if stacked else None,
- "label": _label_cfg(show_label),
- }
- for s in series_data
- ]
- return opt
-
-
-def _option_bar(title, categories, series_data, stacked=False, smooth=False, show_label=False):
- opt = _base_option(title)
- opt["xAxis"] = {"type": "category", "data": categories}
- opt["yAxis"] = {"type": "value"}
- opt["series"] = [
- {
- "name": s["name"],
- "type": "bar",
- "data": s["data"],
- "stack": "total" if stacked else None,
- "label": _label_cfg(show_label),
- "barMaxWidth": 50,
- }
- for s in series_data
- ]
- return opt
-
-
-def _option_pie(title, categories, series_data, stacked=False, smooth=False, show_label=False):
- opt = _base_option(title)
- del opt["grid"]
- opt["tooltip"] = {"trigger": "item", "formatter": "{b}: {c} ({d}%)"}
- # Pie uses the first series; data items are {name, value}
- pie_data = series_data[0]["data"] if series_data else []
- opt["series"] = [
- {
- "name": series_data[0]["name"] if series_data else title,
- "type": "pie",
- "radius": ["40%", "70%"],
- "center": ["50%", "55%"],
- "data": pie_data,
- "emphasis": {"itemStyle": {"shadowBlur": 10, "shadowOffsetX": 0, "shadowColor": "rgba(0,0,0,0.5)"}},
- "label": {"show": True, "formatter": "{b}: {d}%"},
- "itemStyle": {"borderRadius": 6, "borderColor": "#fff", "borderWidth": 2},
- }
- ]
- return opt
-
-
-def _option_radar(title, categories, series_data, stacked=False, smooth=False, show_label=False):
- opt = _base_option(title)
- del opt["grid"]
- opt["tooltip"] = {"trigger": "item"}
- # Determine max value per indicator from all series
- max_vals = [0] * len(categories)
- for s in series_data:
- for i, v in enumerate(s["data"]):
- if i < len(max_vals) and v > max_vals[i]:
- max_vals[i] = v
- indicator = [{"name": c, "max": int(m * 1.2) or 100} for c, m in zip(categories, max_vals)]
- opt["radar"] = {"indicator": indicator, "center": ["50%", "55%"]}
- opt["series"] = [
- {
- "type": "radar",
- "data": [{"value": s["data"], "name": s["name"]} for s in series_data],
- "areaStyle": {"opacity": 0.15},
- }
- ]
- return opt
-
-
-def _option_scatter(title, categories, series_data, stacked=False, smooth=False, show_label=False):
- opt = _base_option(title)
- opt["tooltip"] = {"trigger": "item", "formatter": "{a}: ({c})"}
- opt["xAxis"] = {"type": "value", "scale": True}
- opt["yAxis"] = {"type": "value", "scale": True}
- opt["series"] = [
- {
- "name": s["name"],
- "type": "scatter",
- "data": s["data"],
- "symbolSize": 10,
- "label": _label_cfg(show_label),
- }
- for s in series_data
- ]
- return opt
-
-
-def _option_gauge(title, categories, series_data, stacked=False, smooth=False, show_label=False):
- opt = _base_option(title)
- del opt["grid"]
- opt["tooltip"] = {"trigger": "item"}
- # Gauge uses first series, first data value
- value = 0
- name = title
- if series_data:
- name = series_data[0]["name"]
- data_arr = series_data[0]["data"]
- if data_arr:
- value = data_arr[0] if isinstance(data_arr[0], (int, float)) else 0
- opt["series"] = [
- {
- "type": "gauge",
- "center": ["50%", "60%"],
- "startAngle": 200,
- "endAngle": -20,
- "min": 0,
- "max": 100,
- "detail": {"formatter": "{value}%", "fontSize": 24, "offsetCenter": [0, "60%"]},
- "data": [{"value": value, "name": name}],
- "axisLine": {"lineStyle": {"width": 20}},
- "progress": {"show": True, "width": 20},
- "pointer": {"show": True},
- }
- ]
- return opt
-
-
-# ---------------------------------------------------------------------------
-# Multi-chart HTML builder
-# ---------------------------------------------------------------------------
-
-def _build_multi_chart_html(
- title: str,
- charts: List[Dict[str, Any]],
- columns: int = 2,
- theme: str = "light",
-) -> str:
- """Build a single HTML page containing multiple ECharts in a grid layout."""
- bg_color = "#1a1a2e" if theme == "dark" else "#f8fafc"
- text_color = "#e0e0e0" if theme == "dark" else "#0f172a"
- card_bg = "#252547" if theme == "dark" else "#ffffff"
- border_color = "#3a3a5c" if theme == "dark" else "#e2e8f0"
- echarts_theme = "'dark'" if theme == "dark" else "null"
-
- # Build chart divs and init scripts
- chart_divs = ""
- chart_scripts = ""
- for idx, chart in enumerate(charts):
- chart_id = f"chart_{idx}"
- chart_title = chart.get("title", f"Chart {idx + 1}")
- chart_type = chart.get("chart_type", "line")
- chart_data = chart.get("data", {})
- chart_height = chart.get("height", "350px")
- stacked = chart.get("stacked", False)
- smooth = chart.get("smooth", False)
- show_label = chart.get("show_label", False)
-
- categories = chart_data.get("categories", [])
- series = chart_data.get("series", [])
-
- option = _build_echarts_option(
- chart_title, chart_type, categories, series,
- stacked=stacked, smooth=smooth, show_label=show_label,
- )
- option_json = json.dumps(option, ensure_ascii=False)
-
- chart_divs += f"""
- """
-
- chart_scripts += f"""
- (function() {{
- var el = document.getElementById('{chart_id}');
- var c = echarts.init(el, {echarts_theme});
- c.setOption({option_json});
- charts.push(c);
- }})();"""
-
- # Calculate total height based on rows
- num_rows = (len(charts) + columns - 1) // columns
- # 350px default chart + 32px card padding + 16px gap
- est_row_height = 400
- total_height = 80 + num_rows * est_row_height
-
- return f"""
-
-
-
-
-{_esc(title)}
-
-
-
-
- {_esc(title)}
- {chart_divs}
-
-
-
-"""
-
-
-def _create_ui_resource(uri: str, html: str, width: str = "100%", height: str = "auto") -> Dict[str, Any]:
- """Create a self-contained UIResource with HTML in resource.text."""
- ui_resource = create_ui_resource({
- "uri": uri,
- "content": {"type": "rawHtml", "htmlString": html},
- "encoding": "text",
- "uiMetadata": {
- UIMetadataKey.PREFERRED_FRAME_SIZE: [width, height],
- },
- })
- resource_json = json.dumps(ui_resource.model_dump(mode="json"), ensure_ascii=False)
- return {"content": [{"type": "text", "text": resource_json}]}
-
-
-def _handle_data_viz(uri: str, data: Dict[str, Any]) -> Dict[str, Any]:
- """Unified handler for all data visualization URIs.
-
- Parses URI path to route to the appropriate HTML builder.
- All content is self-contained in resource.text.
+ Returns a JSON payload with type:"app" that tells the host to:
+ 1. Fetch the static HTML App via resources/read(resourceUri)
+ 2. Render it in a sandboxed iframe
+ 3. Send `data` to the iframe via postMessage
"""
- title = data.get("title", "Dashboard")
- theme = data.get("theme", "light")
- path = uri.replace("ui://data-dashboard/", "")
+ app_json = json.dumps({
+ "type": "app",
+ "resourceUri": resource_uri,
+ "data": data,
+ "_meta": {
+ "mcpui.dev/ui-preferred-frame-size": [width, height],
+ },
+ }, ensure_ascii=False)
+ return {"content": [{"type": "text", "text": app_json}]}
- if path == "metrics":
- metrics = data.get("metrics", [])
- if not metrics:
- raise ValueError("Missing required parameter: data.metrics")
- html = _build_dashboard_html(title, metrics)
- return _create_ui_resource(uri, html, "100%", "auto")
- elif path == "chart":
- chart_type = data.get("chart_type")
- chart_data = data.get("data", {})
- if not chart_type:
- raise ValueError("Missing required parameter: data.chart_type")
- if not chart_data or not chart_data.get("series"):
- raise ValueError("Missing required parameter: data.data.series")
+# ---------------------------------------------------------------------------
+# Tool handlers — return data only, no HTML generation
+# ---------------------------------------------------------------------------
- width = data.get("width", "100%")
- height = data.get("height", "400px")
- stacked = data.get("stacked", False)
- smooth = data.get("smooth", False)
- show_label = data.get("show_label", False)
+def _handle_render_metrics(arguments: Dict[str, Any]) -> Dict[str, Any]:
+ title = arguments.get("title", "Dashboard")
+ metrics = arguments.get("metrics", [])
+ if not metrics:
+ raise ValueError("Missing required parameter: metrics")
+ return _create_app_response(
+ "ui://data-dashboard/metrics",
+ {"title": title, "metrics": metrics},
+ "100%", "auto",
+ )
- html = _build_chart_html(
- title, chart_type, chart_data,
- width=width, height=height, theme=theme,
- stacked=stacked, smooth=smooth, show_label=show_label,
- )
- return _create_ui_resource(uri, html, width, height)
- elif path == "multi-chart":
- charts = data.get("charts", [])
- if not charts:
- raise ValueError("Missing required parameter: data.charts")
- for i, c in enumerate(charts):
- if not c.get("chart_type"):
- raise ValueError(f"data.charts[{i}]: missing chart_type")
- if not c.get("data", {}).get("series"):
- raise ValueError(f"data.charts[{i}]: missing data.series")
+def _handle_render_chart(arguments: Dict[str, Any]) -> Dict[str, Any]:
+ title = arguments.get("title", "Chart")
+ chart_type = arguments.get("chart_type")
+ chart_data = arguments.get("data", {})
+ if not chart_type:
+ raise ValueError("Missing required parameter: chart_type")
+ if not chart_data or not chart_data.get("series"):
+ raise ValueError("Missing required parameter: data.series")
- columns = data.get("columns", 2)
- html = _build_multi_chart_html(title, charts, columns=columns, theme=theme)
- num_rows = (len(charts) + columns - 1) // columns
- total_height = f"{80 + num_rows * 420}px"
- return _create_ui_resource(uri, html, "100%", total_height)
+ width = arguments.get("width", "100%")
+ height = arguments.get("height", "400px")
- else:
- raise ValueError(f"Unknown URI path: {path}")
+ return _create_app_response(
+ "ui://data-dashboard/chart",
+ {
+ "title": title,
+ "chart_type": chart_type,
+ "data": chart_data,
+ "theme": arguments.get("theme", "light"),
+ "stacked": arguments.get("stacked", False),
+ "smooth": arguments.get("smooth", False),
+ "show_label": arguments.get("show_label", False),
+ },
+ width, height,
+ )
+
+
+def _handle_render_multi_chart(arguments: Dict[str, Any]) -> Dict[str, Any]:
+ title = arguments.get("title", "Dashboard")
+ charts = arguments.get("charts", [])
+ if not charts:
+ raise ValueError("Missing required parameter: charts")
+ for i, c in enumerate(charts):
+ if not c.get("chart_type"):
+ raise ValueError(f"charts[{i}]: missing chart_type")
+ if not c.get("data", {}).get("series"):
+ raise ValueError(f"charts[{i}]: missing data.series")
+
+ columns = arguments.get("columns", 2)
+ num_rows = (len(charts) + columns - 1) // columns
+ total_height = f"{80 + num_rows * 420}px"
+
+ return _create_app_response(
+ "ui://data-dashboard/multi-chart",
+ {
+ "title": title,
+ "charts": charts,
+ "columns": columns,
+ "theme": arguments.get("theme", "light"),
+ },
+ "100%", total_height,
+ )
+
+
+TOOL_HANDLERS = {
+ "render_metrics": _handle_render_metrics,
+ "render_chart": _handle_render_chart,
+ "render_multi_chart": _handle_render_multi_chart,
+}
async def handle_request(request: Dict[str, Any]) -> Dict[str, Any]:
@@ -463,7 +170,21 @@ async def handle_request(request: Dict[str, Any]) -> Dict[str, Any]:
request_id = request.get("id")
if method == "initialize":
- return create_initialize_response(request_id, "data-dashboard")
+ return {
+ "jsonrpc": "2.0",
+ "id": request_id,
+ "result": {
+ "protocolVersion": "2024-11-05",
+ "capabilities": {
+ "tools": {},
+ "resources": {},
+ },
+ "serverInfo": {
+ "name": "data-dashboard",
+ "version": "3.0.0",
+ },
+ },
+ }
elif method == "ping":
return create_ping_response(request_id)
@@ -473,49 +194,63 @@ async def handle_request(request: Dict[str, Any]) -> Dict[str, Any]:
if not tools:
tools = [
{
- "name": "render_data_viz",
- "description": "Render data visualization. Use uri to specify type.",
- "inputSchema": {
- "type": "object",
- "properties": {
- "uri": {"type": "string"},
- "title": {"type": "string"},
- },
- "required": ["uri", "title"],
- },
+ "name": name,
+ "description": f"Render {name.replace('render_', '')}",
+ "inputSchema": {"type": "object", "properties": {}, "required": []},
+ "_meta": {"ui": {"resourceUri": uri}},
}
+ for name, uri in [
+ ("render_metrics", "ui://data-dashboard/metrics"),
+ ("render_chart", "ui://data-dashboard/chart"),
+ ("render_multi_chart", "ui://data-dashboard/multi-chart"),
+ ]
]
return create_tools_list_response(request_id, tools)
+ elif method == "resources/list":
+ return {
+ "jsonrpc": "2.0",
+ "id": request_id,
+ "result": {"resources": RESOURCE_DEFINITIONS},
+ }
+
+ elif method == "resources/read":
+ uri = params.get("uri", "")
+ try:
+ html = _load_app_html(uri)
+ except (ValueError, FileNotFoundError) as e:
+ return create_error_response(request_id, -32602, str(e))
+ return {
+ "jsonrpc": "2.0",
+ "id": request_id,
+ "result": {
+ "contents": [
+ {
+ "uri": uri,
+ "mimeType": RESOURCE_MIME_TYPE,
+ "text": html,
+ }
+ ]
+ },
+ }
+
elif method == "tools/call":
tool_name = params.get("name")
arguments = params.get("arguments", {})
- if tool_name == "render_data_viz":
- uri = arguments.get("uri", "")
- data = arguments.get("data", {})
- if not uri or not uri.startswith("ui://data-dashboard/"):
- return create_error_response(
- request_id, -32602,
- "Invalid uri. Must be one of: ui://data-dashboard/metrics, ui://data-dashboard/chart, ui://data-dashboard/multi-chart"
- )
+ handler = TOOL_HANDLERS.get(tool_name)
+ if not handler:
+ return create_error_response(request_id, -32601, f"Unknown tool: {tool_name}")
- try:
- result = _handle_data_viz(uri, data)
- except ValueError as e:
- return create_error_response(request_id, -32602, str(e))
+ try:
+ result = handler(arguments)
+ except ValueError as e:
+ return create_error_response(request_id, -32602, str(e))
- return {"jsonrpc": "2.0", "id": request_id, "result": result}
-
- else:
- return create_error_response(
- request_id, -32601, f"Unknown tool: {tool_name}"
- )
+ return {"jsonrpc": "2.0", "id": request_id, "result": result}
else:
- return create_error_response(
- request_id, -32601, f"Unknown method: {method}"
- )
+ return create_error_response(request_id, -32601, f"Unknown method: {method}")
except Exception as e:
return create_error_response(
@@ -524,7 +259,6 @@ async def handle_request(request: Dict[str, Any]) -> Dict[str, Any]:
async def main():
- """Main entry point."""
await handle_mcp_streaming(handle_request)
diff --git a/skills/common/data-dashboard/dashboard_tools.json b/skills/common/data-dashboard/dashboard_tools.json
index 248990c..c903126 100644
--- a/skills/common/data-dashboard/dashboard_tools.json
+++ b/skills/common/data-dashboard/dashboard_tools.json
@@ -1,143 +1,181 @@
[
{
- "name": "render_data_viz",
- "description": "Render data visualization resources. Use the `uri` field to specify the visualization type, and pass all parameters in the `data` object. All charts use ECharts library.\n\nSupported URIs:\n- `ui://data-dashboard/metrics` — Metric card dashboard. data: {title, metrics: [{label, value, change?, change_type?}]}\n- `ui://data-dashboard/chart` — Single chart. data: {title, chart_type, data: {categories?, series}, width?, height?, stacked?, smooth?, show_label?, theme?}\n- `ui://data-dashboard/multi-chart` — Multiple charts in grid. data: {title, charts: [{title, chart_type, data, height?, stacked?, smooth?, show_label?}], columns?, theme?}",
+ "name": "render_metrics",
+ "description": "Render a metric card dashboard showing KPIs, stats, and numerical summaries.",
"inputSchema": {
"type": "object",
"properties": {
- "uri": {
+ "title": {
"type": "string",
- "enum": ["ui://data-dashboard/metrics", "ui://data-dashboard/chart", "ui://data-dashboard/multi-chart"],
- "description": "Resource URI that determines the visualization type"
+ "description": "Title displayed at the top of the dashboard"
},
- "data": {
- "type": "object",
- "description": "Parameters for the specified URI. Structure depends on uri:\n- metrics: {title, metrics: [{label, value, change?, change_type?}]}\n- chart: {title, chart_type, data: {categories?, series}, width?, height?, stacked?, smooth?, show_label?, theme?}\n- multi-chart: {title, charts: [{title, chart_type, data, height?, stacked?, smooth?, show_label?}], columns?, theme?}",
- "properties": {
- "title": {
- "type": "string",
- "description": "Title displayed at the top of the visualization"
+ "metrics": {
+ "type": "array",
+ "description": "Array of metric objects to display as cards",
+ "items": {
+ "type": "object",
+ "properties": {
+ "label": { "type": "string", "description": "Metric name" },
+ "value": { "type": "string", "description": "Metric value" },
+ "change": { "type": "string", "description": "Change indicator, e.g. '+12.5%'" },
+ "change_type": { "type": "string", "enum": ["up", "down", "neutral"], "description": "Direction of change" }
},
- "metrics": {
- "type": "array",
- "description": "[uri=metrics] Array of metric objects to display as cards",
- "items": {
- "type": "object",
- "properties": {
- "label": { "type": "string", "description": "Metric name" },
- "value": { "type": "string", "description": "Metric value" },
- "change": { "type": "string", "description": "Change indicator, e.g. '+12.5%'" },
- "change_type": { "type": "string", "enum": ["up", "down", "neutral"], "description": "Direction of change" }
- },
- "required": ["label", "value"]
- }
- },
- "chart_type": {
- "type": "string",
- "enum": ["line", "bar", "pie", "radar", "scatter", "gauge"],
- "description": "[uri=chart] Type of chart to render"
- },
- "data": {
- "type": "object",
- "description": "[uri=chart] Chart data object",
- "properties": {
- "categories": {
- "type": "array",
- "items": { "type": "string" },
- "description": "X-axis labels (for line, bar, radar)"
- },
- "series": {
- "type": "array",
- "description": "Array of data series",
- "items": {
- "type": "object",
- "properties": {
- "name": { "type": "string", "description": "Series name" },
- "data": { "type": "array", "description": "Data values" }
- },
- "required": ["name", "data"]
- }
- }
- },
- "required": ["series"]
- },
- "width": {
- "type": "string",
- "description": "[uri=chart] CSS width. Default: '100%'",
- "default": "100%"
- },
- "height": {
- "type": "string",
- "description": "[uri=chart] CSS height. Default: '400px'",
- "default": "400px"
- },
- "stacked": {
- "type": "boolean",
- "description": "[uri=chart] Stack series (line/bar). Default: false",
- "default": false
- },
- "smooth": {
- "type": "boolean",
- "description": "[uri=chart] Smooth curves (line). Default: false",
- "default": false
- },
- "show_label": {
- "type": "boolean",
- "description": "[uri=chart] Show data labels. Default: false",
- "default": false
- },
- "theme": {
- "type": "string",
- "enum": ["light", "dark"],
- "description": "Color theme. Default: 'light'",
- "default": "light"
- },
- "columns": {
- "type": "integer",
- "description": "[uri=multi-chart] Grid columns (1-4). Default: 2",
- "default": 2,
- "minimum": 1,
- "maximum": 4
- },
- "charts": {
- "type": "array",
- "description": "[uri=multi-chart] Array of chart objects",
- "items": {
- "type": "object",
- "properties": {
- "title": { "type": "string", "description": "Chart title" },
- "chart_type": { "type": "string", "enum": ["line", "bar", "pie", "radar", "scatter", "gauge"], "description": "Type of chart" },
- "data": {
- "type": "object",
- "description": "Chart data",
- "properties": {
- "categories": { "type": "array", "items": { "type": "string" } },
- "series": {
- "type": "array",
- "items": {
- "type": "object",
- "properties": {
- "name": { "type": "string" },
- "data": { "type": "array" }
- },
- "required": ["name", "data"]
- }
- }
- },
- "required": ["series"]
- },
- "height": { "type": "string", "default": "350px" },
- "stacked": { "type": "boolean", "default": false },
- "smooth": { "type": "boolean", "default": false },
- "show_label": { "type": "boolean", "default": false }
- },
- "required": ["title", "chart_type", "data"]
- }
- }
+ "required": ["label", "value"]
}
}
},
- "required": ["uri", "data"]
+ "required": ["title", "metrics"]
+ },
+ "_meta": {
+ "ui": {
+ "resourceUri": "ui://data-dashboard/metrics"
+ }
+ }
+ },
+ {
+ "name": "render_chart",
+ "description": "Render a single ECharts chart. Supports line, bar, pie, radar, scatter, and gauge chart types.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "title": {
+ "type": "string",
+ "description": "Chart title"
+ },
+ "chart_type": {
+ "type": "string",
+ "enum": ["line", "bar", "pie", "radar", "scatter", "gauge"],
+ "description": "Type of chart to render"
+ },
+ "data": {
+ "type": "object",
+ "description": "Chart data object",
+ "properties": {
+ "categories": {
+ "type": "array",
+ "items": { "type": "string" },
+ "description": "X-axis labels (for line, bar, radar)"
+ },
+ "series": {
+ "type": "array",
+ "description": "Array of data series",
+ "items": {
+ "type": "object",
+ "properties": {
+ "name": { "type": "string", "description": "Series name" },
+ "data": { "type": "array", "description": "Data values" }
+ },
+ "required": ["name", "data"]
+ }
+ }
+ },
+ "required": ["series"]
+ },
+ "width": {
+ "type": "string",
+ "description": "CSS width. Default: '100%'",
+ "default": "100%"
+ },
+ "height": {
+ "type": "string",
+ "description": "CSS height. Default: '400px'",
+ "default": "400px"
+ },
+ "stacked": {
+ "type": "boolean",
+ "description": "Stack series (line/bar). Default: false",
+ "default": false
+ },
+ "smooth": {
+ "type": "boolean",
+ "description": "Smooth curves (line). Default: false",
+ "default": false
+ },
+ "show_label": {
+ "type": "boolean",
+ "description": "Show data labels. Default: false",
+ "default": false
+ },
+ "theme": {
+ "type": "string",
+ "enum": ["light", "dark"],
+ "description": "Color theme. Default: 'light'",
+ "default": "light"
+ }
+ },
+ "required": ["title", "chart_type", "data"]
+ },
+ "_meta": {
+ "ui": {
+ "resourceUri": "ui://data-dashboard/chart"
+ }
+ }
+ },
+ {
+ "name": "render_multi_chart",
+ "description": "Render multiple ECharts charts in a grid layout. Use for comprehensive overviews and business reports.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "title": {
+ "type": "string",
+ "description": "Overall dashboard title"
+ },
+ "charts": {
+ "type": "array",
+ "description": "Array of chart objects",
+ "items": {
+ "type": "object",
+ "properties": {
+ "title": { "type": "string", "description": "Chart title" },
+ "chart_type": { "type": "string", "enum": ["line", "bar", "pie", "radar", "scatter", "gauge"], "description": "Type of chart" },
+ "data": {
+ "type": "object",
+ "description": "Chart data",
+ "properties": {
+ "categories": { "type": "array", "items": { "type": "string" } },
+ "series": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "name": { "type": "string" },
+ "data": { "type": "array" }
+ },
+ "required": ["name", "data"]
+ }
+ }
+ },
+ "required": ["series"]
+ },
+ "height": { "type": "string", "default": "350px" },
+ "stacked": { "type": "boolean", "default": false },
+ "smooth": { "type": "boolean", "default": false },
+ "show_label": { "type": "boolean", "default": false }
+ },
+ "required": ["title", "chart_type", "data"]
+ }
+ },
+ "columns": {
+ "type": "integer",
+ "description": "Grid columns (1-4). Default: 2",
+ "default": 2,
+ "minimum": 1,
+ "maximum": 4
+ },
+ "theme": {
+ "type": "string",
+ "enum": ["light", "dark"],
+ "description": "Color theme. Default: 'light'",
+ "default": "light"
+ }
+ },
+ "required": ["title", "charts"]
+ },
+ "_meta": {
+ "ui": {
+ "resourceUri": "ui://data-dashboard/multi-chart"
+ }
}
}
]
diff --git a/skills/common/data-dashboard/hooks/dashboard_guide.md b/skills/common/data-dashboard/hooks/dashboard_guide.md
index 7d6bb3a..2227920 100644
--- a/skills/common/data-dashboard/hooks/dashboard_guide.md
+++ b/skills/common/data-dashboard/hooks/dashboard_guide.md
@@ -1,147 +1,124 @@
-## `render_data_viz` usage guide
+## Data Visualization Tools Usage Guide
-This tool renders data visualization resources. Use the `uri` field to specify the visualization type, and pass all parameters in the `data` object.
-
-### Supported URIs:
-- `ui://data-dashboard/metrics` — Metric card dashboard
-- `ui://data-dashboard/chart` — Single chart (line, bar, pie, radar, scatter, gauge)
-- `ui://data-dashboard/multi-chart` — Multiple charts in a grid layout
+Three tools are available for rendering data visualizations:
+- `render_metrics` — Metric card dashboard (KPIs, stats)
+- `render_chart` — Single chart (line, bar, pie, radar, scatter, gauge)
+- `render_multi_chart` — Multiple charts in a grid layout
---
-### 1. Metrics — `ui://data-dashboard/metrics`
+### 1. render_metrics
When to use: KPIs, stats, numerical summaries, side-by-side comparisons.
```
-render_data_viz(
- uri="ui://data-dashboard/metrics",
- data={
- "title": "Sales Overview",
- "metrics": [
- {"label": "Revenue", "value": "$12,345", "change": "+12.5%", "change_type": "up"},
- {"label": "Users", "value": "1,234", "change": "-3.2%", "change_type": "down"},
- {"label": "Conversion", "value": "4.5%", "change_type": "neutral"}
- ]
- }
+render_metrics(
+ title="Sales Overview",
+ metrics=[
+ {"label": "Revenue", "value": "$12,345", "change": "+12.5%", "change_type": "up"},
+ {"label": "Users", "value": "1,234", "change": "-3.2%", "change_type": "down"},
+ {"label": "Conversion", "value": "4.5%", "change_type": "neutral"}
+ ]
)
```
---
-### 2. Chart — `ui://data-dashboard/chart`
+### 2. render_chart
When to use: trends, comparisons, distributions, compositions, single metric gauges.
#### Line chart — trends over time
```
-render_data_viz(
- uri="ui://data-dashboard/chart",
+render_chart(
+ title="Monthly Revenue",
+ chart_type="line",
data={
- "title": "Monthly Revenue",
- "chart_type": "line",
- "data": {
- "categories": ["Jan", "Feb", "Mar", "Apr", "May", "Jun"],
- "series": [
- {"name": "2024", "data": [820, 932, 901, 934, 1290, 1330]},
- {"name": "2025", "data": [620, 732, 801, 1034, 1190, 1530]}
- ]
- },
- "smooth": true
- }
+ "categories": ["Jan", "Feb", "Mar", "Apr", "May", "Jun"],
+ "series": [
+ {"name": "2024", "data": [820, 932, 901, 934, 1290, 1330]},
+ {"name": "2025", "data": [620, 732, 801, 1034, 1190, 1530]}
+ ]
+ },
+ smooth=true
)
```
#### Bar chart — comparisons
```
-render_data_viz(
- uri="ui://data-dashboard/chart",
+render_chart(
+ title="Sales by Region",
+ chart_type="bar",
data={
- "title": "Sales by Region",
- "chart_type": "bar",
- "data": {
- "categories": ["North", "South", "East", "West"],
- "series": [
- {"name": "Q1", "data": [320, 302, 341, 374]},
- {"name": "Q2", "data": [220, 182, 191, 234]}
- ]
- },
- "stacked": true,
- "show_label": true
- }
+ "categories": ["North", "South", "East", "West"],
+ "series": [
+ {"name": "Q1", "data": [320, 302, 341, 374]},
+ {"name": "Q2", "data": [220, 182, 191, 234]}
+ ]
+ },
+ stacked=true,
+ show_label=true
)
```
#### Pie chart — proportions
```
-render_data_viz(
- uri="ui://data-dashboard/chart",
+render_chart(
+ title="Traffic Sources",
+ chart_type="pie",
data={
- "title": "Traffic Sources",
- "chart_type": "pie",
- "data": {
- "series": [{
- "name": "Sources",
- "data": [
- {"name": "Search", "value": 1048},
- {"name": "Direct", "value": 735},
- {"name": "Email", "value": 580},
- {"name": "Social", "value": 484}
- ]
- }]
- }
+ "series": [{
+ "name": "Sources",
+ "data": [
+ {"name": "Search", "value": 1048},
+ {"name": "Direct", "value": 735},
+ {"name": "Email", "value": 580},
+ {"name": "Social", "value": 484}
+ ]
+ }]
}
)
```
#### Radar chart — multi-dimensional
```
-render_data_viz(
- uri="ui://data-dashboard/chart",
+render_chart(
+ title="Skill Assessment",
+ chart_type="radar",
data={
- "title": "Skill Assessment",
- "chart_type": "radar",
- "data": {
- "categories": ["Sales", "Admin", "Tech", "Support", "Marketing"],
- "series": [
- {"name": "Alice", "data": [4200, 3000, 20000, 35000, 50000]},
- {"name": "Bob", "data": [5000, 14000, 28000, 26000, 42000]}
- ]
- }
+ "categories": ["Sales", "Admin", "Tech", "Support", "Marketing"],
+ "series": [
+ {"name": "Alice", "data": [4200, 3000, 20000, 35000, 50000]},
+ {"name": "Bob", "data": [5000, 14000, 28000, 26000, 42000]}
+ ]
}
)
```
#### Scatter chart — correlation
```
-render_data_viz(
- uri="ui://data-dashboard/chart",
+render_chart(
+ title="Height vs Weight",
+ chart_type="scatter",
data={
- "title": "Height vs Weight",
- "chart_type": "scatter",
- "data": {
- "series": [
- {"name": "Male", "data": [[161, 51], [167, 59], [159, 49], [175, 73]]},
- {"name": "Female", "data": [[150, 45], [160, 55], [165, 60], [155, 50]]}
- ]
- }
+ "series": [
+ {"name": "Male", "data": [[161, 51], [167, 59], [159, 49], [175, 73]]},
+ {"name": "Female", "data": [[150, 45], [160, 55], [165, 60], [155, 50]]}
+ ]
}
)
```
#### Gauge chart — single KPI
```
-render_data_viz(
- uri="ui://data-dashboard/chart",
- data={
- "title": "CPU Usage",
- "chart_type": "gauge",
- "data": {"series": [{"name": "CPU", "data": [72.5]}]}
- }
+render_chart(
+ title="CPU Usage",
+ chart_type="gauge",
+ data={"series": [{"name": "CPU", "data": [72.5]}]}
)
```
-#### Chart options (inside data):
+#### Chart options:
- `width`: CSS width, default "100%"
- `height`: CSS height, default "400px"
- `theme`: "light" (default) or "dark"
@@ -151,57 +128,54 @@ render_data_viz(
---
-### 3. Multi-chart — `ui://data-dashboard/multi-chart`
+### 3. render_multi_chart
When to use: comprehensive overviews, multiple related charts, business reports.
```
-render_data_viz(
- uri="ui://data-dashboard/multi-chart",
- data={
- "title": "Monthly Business Report",
- "columns": 2,
- "theme": "light",
- "charts": [
- {
- "title": "Revenue Trend",
- "chart_type": "line",
- "data": {
- "categories": ["Jan", "Feb", "Mar", "Apr", "May", "Jun"],
- "series": [{"name": "Revenue", "data": [820, 932, 901, 934, 1290, 1330]}]
- },
- "smooth": true
+render_multi_chart(
+ title="Monthly Business Report",
+ columns=2,
+ theme="light",
+ charts=[
+ {
+ "title": "Revenue Trend",
+ "chart_type": "line",
+ "data": {
+ "categories": ["Jan", "Feb", "Mar", "Apr", "May", "Jun"],
+ "series": [{"name": "Revenue", "data": [820, 932, 901, 934, 1290, 1330]}]
},
- {
- "title": "Sales by Region",
- "chart_type": "bar",
- "data": {
- "categories": ["North", "South", "East", "West"],
- "series": [
- {"name": "Q1", "data": [320, 302, 341, 374]},
- {"name": "Q2", "data": [220, 182, 191, 234]}
- ]
- }
- },
- {
- "title": "Traffic Sources",
- "chart_type": "pie",
- "data": {
- "series": [{"name": "Sources", "data": [
- {"name": "Search", "value": 1048},
- {"name": "Direct", "value": 735},
- {"name": "Social", "value": 484}
- ]}]
- }
- },
- {
- "title": "Server Load",
- "chart_type": "gauge",
- "data": {"series": [{"name": "CPU", "data": [67]}]},
- "height": "300px"
+ "smooth": true
+ },
+ {
+ "title": "Sales by Region",
+ "chart_type": "bar",
+ "data": {
+ "categories": ["North", "South", "East", "West"],
+ "series": [
+ {"name": "Q1", "data": [320, 302, 341, 374]},
+ {"name": "Q2", "data": [220, 182, 191, 234]}
+ ]
}
- ]
- }
+ },
+ {
+ "title": "Traffic Sources",
+ "chart_type": "pie",
+ "data": {
+ "series": [{"name": "Sources", "data": [
+ {"name": "Search", "value": 1048},
+ {"name": "Direct", "value": 735},
+ {"name": "Social", "value": 484}
+ ]}]
+ }
+ },
+ {
+ "title": "Server Load",
+ "chart_type": "gauge",
+ "data": {"series": [{"name": "CPU", "data": [67]}]},
+ "height": "300px"
+ }
+ ]
)
```
diff --git a/skills/common/mcp-ui/apps/ask-user.html b/skills/common/mcp-ui/apps/ask-user.html
new file mode 100644
index 0000000..d5d7162
--- /dev/null
+++ b/skills/common/mcp-ui/apps/ask-user.html
@@ -0,0 +1,103 @@
+
+
+
+
+
+Ask User
+
+
+
+
+
+
+
diff --git a/skills/common/mcp-ui/apps/html.html b/skills/common/mcp-ui/apps/html.html
new file mode 100644
index 0000000..506df38
--- /dev/null
+++ b/skills/common/mcp-ui/apps/html.html
@@ -0,0 +1,44 @@
+
+
+
+
+
+HTML Renderer
+
+
+
+
+
+
+
diff --git a/skills/common/mcp-ui/apps/url.html b/skills/common/mcp-ui/apps/url.html
new file mode 100644
index 0000000..3e5e4fa
--- /dev/null
+++ b/skills/common/mcp-ui/apps/url.html
@@ -0,0 +1,33 @@
+
+
+
+
+
+URL Embed
+
+
+
+
+
+
+
diff --git a/skills/common/mcp-ui/hooks/ask_user_guide.md b/skills/common/mcp-ui/hooks/ask_user_guide.md
index 7af29f3..9e44a31 100644
--- a/skills/common/mcp-ui/hooks/ask_user_guide.md
+++ b/skills/common/mcp-ui/hooks/ask_user_guide.md
@@ -17,24 +17,20 @@ Example: If the question is "What is the topic of the PPT?", do NOT leave option
Do NOT call ask_user with empty options arrays.
-### How to call render_ui for ask-user
-Use `uri` + `data` format:
+### How to call ask_user
```json
-render_ui(
- uri="ui://mcp-ui/ask-user",
- data={
- "title": "A descriptive title",
- "questions": [
- {
- "question": "Who is the audience?",
- "options": ["Leadership", "Team", "Client"]
- },
- {
- "question": "How long is the presentation?",
- "options": ["5-10 minutes", "15-20 minutes", "30+ minutes"]
- }
- ]
- }
+ask_user(
+ title="A descriptive title",
+ questions=[
+ {
+ "question": "Who is the audience?",
+ "options": ["Leadership", "Team", "Client"]
+ },
+ {
+ "question": "How long is the presentation?",
+ "options": ["5-10 minutes", "15-20 minutes", "30+ minutes"]
+ }
+ ]
)
```
diff --git a/skills/common/mcp-ui/mcp_ui_tools.json b/skills/common/mcp-ui/mcp_ui_tools.json
index 4bd3763..04d29c8 100644
--- a/skills/common/mcp-ui/mcp_ui_tools.json
+++ b/skills/common/mcp-ui/mcp_ui_tools.json
@@ -1,70 +1,112 @@
[
{
- "name": "render_ui",
- "description": "Render an interactive UI resource in the chat. Use the `uri` field to specify the resource type, and pass all parameters in the `data` object.\n\nSupported URIs:\n- `ui://mcp-ui/html` — Render custom HTML/CSS/JS. data: {html_content, title?, width?, height?}\n- `ui://mcp-ui/url` — Embed an external URL in an iframe. data: {url, title?, width?, height?}\n- `ui://mcp-ui/ask-user` — Present questions with selectable options. data: {questions: [{question, options, multi_select?}], title?}",
+ "name": "render_html",
+ "description": "Render custom HTML/CSS/JS content in the chat as an interactive UI widget.",
"inputSchema": {
"type": "object",
"properties": {
- "uri": {
+ "title": {
"type": "string",
- "enum": ["ui://mcp-ui/html", "ui://mcp-ui/url", "ui://mcp-ui/ask-user"],
- "description": "Resource URI that determines the rendering mode"
+ "description": "A descriptive title for the UI widget"
},
- "data": {
- "type": "object",
- "description": "Parameters for the specified URI. Structure depends on uri:\n- html: {html_content: string, title?: string, width?: string, height?: string}\n- url: {url: string, title?: string, width?: string, height?: string}\n- ask-user: {questions: [{question: string, options: string[], multi_select?: boolean}], title?: string}",
- "properties": {
- "title": {
- "type": "string",
- "description": "A descriptive title for the UI widget"
- },
- "html_content": {
- "type": "string",
- "description": "[uri=html] Complete HTML content to render. Can include inline CSS and JavaScript."
- },
- "url": {
- "type": "string",
- "description": "[uri=url] External URL to embed in an iframe."
- },
- "width": {
- "type": "string",
- "description": "[uri=html|url] CSS width. Default: '100%'",
- "default": "100%"
- },
- "height": {
- "type": "string",
- "description": "[uri=html|url] CSS height. Default: 'auto'",
- "default": "auto"
- },
- "questions": {
- "type": "array",
- "description": "[uri=ask-user] Array of questions to ask the user.",
- "items": {
- "type": "object",
- "properties": {
- "question": {
- "type": "string",
- "description": "The question to ask the user"
- },
- "options": {
- "type": "array",
- "minItems": 2,
- "items": { "type": "string" },
- "description": "REQUIRED array with at least 2 options."
- },
- "multi_select": {
- "type": "boolean",
- "description": "If true, the user can select multiple options. Default: false.",
- "default": false
- }
- },
- "required": ["question", "options"]
+ "html_content": {
+ "type": "string",
+ "description": "Complete HTML content to render. Can include inline CSS and JavaScript."
+ },
+ "width": {
+ "type": "string",
+ "description": "CSS width. Default: '100%'",
+ "default": "100%"
+ },
+ "height": {
+ "type": "string",
+ "description": "CSS height. Default: 'auto'",
+ "default": "auto"
+ }
+ },
+ "required": ["title", "html_content"]
+ },
+ "_meta": {
+ "ui": {
+ "resourceUri": "ui://mcp-ui/html"
+ }
+ }
+ },
+ {
+ "name": "render_url",
+ "description": "Embed an external URL in an iframe in the chat.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "title": {
+ "type": "string",
+ "description": "A descriptive title for the embedded content"
+ },
+ "url": {
+ "type": "string",
+ "description": "External URL to embed in an iframe"
+ },
+ "width": {
+ "type": "string",
+ "description": "CSS width. Default: '100%'",
+ "default": "100%"
+ },
+ "height": {
+ "type": "string",
+ "description": "CSS height. Default: 'auto'",
+ "default": "auto"
+ }
+ },
+ "required": ["title", "url"]
+ },
+ "_meta": {
+ "ui": {
+ "resourceUri": "ui://mcp-ui/url"
+ }
+ }
+ },
+ {
+ "name": "ask_user",
+ "description": "Present questions with selectable options to the user. Each question MUST have at least 2 options.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "title": {
+ "type": "string",
+ "description": "A descriptive title for the question panel"
+ },
+ "questions": {
+ "type": "array",
+ "description": "Array of questions to ask the user",
+ "items": {
+ "type": "object",
+ "properties": {
+ "question": {
+ "type": "string",
+ "description": "The question to ask the user"
+ },
+ "options": {
+ "type": "array",
+ "minItems": 2,
+ "items": { "type": "string" },
+ "description": "REQUIRED array with at least 2 options"
+ },
+ "multi_select": {
+ "type": "boolean",
+ "description": "If true, the user can select multiple options. Default: false",
+ "default": false
}
- }
+ },
+ "required": ["question", "options"]
}
}
},
- "required": ["uri", "data"]
+ "required": ["title", "questions"]
+ },
+ "_meta": {
+ "ui": {
+ "resourceUri": "ui://mcp-ui/ask-user"
+ }
}
}
]
diff --git a/skills/common/mcp-ui/ui_render_server.py b/skills/common/mcp-ui/ui_render_server.py
index d430dc7..c48c860 100644
--- a/skills/common/mcp-ui/ui_render_server.py
+++ b/skills/common/mcp-ui/ui_render_server.py
@@ -1,75 +1,135 @@
#!/usr/bin/env python3
"""
-MCP UI Server - provides interactive UI rendering tools.
-Uses URI-based routing: ui://mcp-ui/[resource-type]
+MCP UI Server - standard MCP Apps protocol (SEP-1865).
+
+- tools/call returns structured data only (no HTML)
+- resources/read returns static HTML App files
+- Host renders HTML App in iframe, passes tool data via postMessage
"""
import asyncio
import json
-import sys
+import os
from typing import Any, Dict
-from mcp_ui_server import create_ui_resource, UIMetadataKey
-
from mcp_common import (
create_error_response,
- create_initialize_response,
create_ping_response,
create_tools_list_response,
load_tools_from_json,
handle_mcp_streaming,
)
+RESOURCE_MIME_TYPE = "text/html;profile=mcp-app"
+URI_MIME_TYPE = "text/uri-list"
+APPS_DIR = os.path.join(os.path.dirname(__file__), "apps")
-def _serialize_ui_resource(ui_resource) -> str:
- """Serialize a UIResource to JSON string."""
- return json.dumps(ui_resource.model_dump(mode="json"), ensure_ascii=False)
+# Resource URI -> static HTML App file mapping
+RESOURCE_MAP = {
+ "ui://mcp-ui/html": "html.html",
+ "ui://mcp-ui/url": "url.html",
+ "ui://mcp-ui/ask-user": "ask-user.html",
+}
+
+RESOURCE_DEFINITIONS = [
+ {
+ "uri": "ui://mcp-ui/html",
+ "name": "html-renderer",
+ "title": "HTML Renderer",
+ "description": "Renders custom HTML/CSS/JS content",
+ "mimeType": RESOURCE_MIME_TYPE,
+ },
+ {
+ "uri": "ui://mcp-ui/url",
+ "name": "url-embed",
+ "title": "URL Embed",
+ "description": "Embeds an external URL in an iframe",
+ "mimeType": URI_MIME_TYPE,
+ },
+ {
+ "uri": "ui://mcp-ui/ask-user",
+ "name": "ask-user",
+ "title": "Ask User",
+ "description": "Presents interactive questions with selectable options",
+ "mimeType": RESOURCE_MIME_TYPE,
+ },
+]
-def _handle_render_ui(uri: str, data: Dict[str, Any]) -> Dict[str, Any]:
- """Unified handler for all render_ui URIs.
+def _load_app_html(uri: str) -> str:
+ """Load static HTML App file for the given resource URI."""
+ filename = RESOURCE_MAP.get(uri)
+ if not filename:
+ raise ValueError(f"Unknown resource URI: {uri}")
+ filepath = os.path.join(APPS_DIR, filename)
+ with open(filepath, "r", encoding="utf-8") as f:
+ return f.read()
- Content is self-contained in the TOOL_RESPONSE resource —
- the frontend only needs TOOL_RESPONSE to render, no TOOL_CALL reference needed.
+
+def _create_app_response(resource_uri: str, data: Dict[str, Any],
+ width: str = "100%", height: str = "auto") -> Dict[str, Any]:
+ """Create a tool result for MCP Apps protocol.
+
+ Returns a JSON payload with type:"app" that tells the host to:
+ 1. Fetch the static HTML App via resources/read(resourceUri)
+ 2. Render it in a sandboxed iframe
+ 3. Send `data` to the iframe via postMessage
"""
- width = data.get("width", "100%")
- height = data.get("height", "auto")
+ app_json = json.dumps({
+ "type": "app",
+ "resourceUri": resource_uri,
+ "data": data,
+ "_meta": {
+ "mcpui.dev/ui-preferred-frame-size": [width, height],
+ },
+ }, ensure_ascii=False)
+ return {"content": [{"type": "text", "text": app_json}]}
- if uri == "ui://mcp-ui/ask-user":
- questions = data.get("questions", [])
- resource = create_ui_resource({
- "uri": uri,
- "content": {"type": "rawHtml", "htmlString": json.dumps({"questions": questions}, ensure_ascii=False)},
- "encoding": "text",
- "uiMetadata": {
- "interactive": True,
- },
- })
- elif uri == "ui://mcp-ui/url":
- url = data.get("url", "")
- resource = create_ui_resource({
- "uri": uri,
- "content": {"type": "externalUrl", "iframeUrl": url},
- "encoding": "text",
- "uiMetadata": {
- UIMetadataKey.PREFERRED_FRAME_SIZE: [width, height],
- "interactive": False,
- },
- })
- else:
- # ui://mcp-ui/html (default)
- html_content = data.get("html_content", "")
- resource = create_ui_resource({
- "uri": uri,
- "content": {"type": "rawHtml", "htmlString": html_content},
- "encoding": "text",
- "uiMetadata": {
- UIMetadataKey.PREFERRED_FRAME_SIZE: [width, height],
- "interactive": False,
- },
- })
- return {"content": [{"type": "text", "text": _serialize_ui_resource(resource)}]}
+# ---------------------------------------------------------------------------
+# Tool handlers — return data only
+# ---------------------------------------------------------------------------
+
+def _handle_render_html(arguments: Dict[str, Any]) -> Dict[str, Any]:
+ html_content = arguments.get("html_content", "")
+ if not html_content:
+ raise ValueError("Missing required parameter: html_content")
+ return _create_app_response(
+ "ui://mcp-ui/html",
+ {"html_content": html_content},
+ arguments.get("width", "100%"),
+ arguments.get("height", "auto"),
+ )
+
+
+def _handle_render_url(arguments: Dict[str, Any]) -> Dict[str, Any]:
+ url = arguments.get("url", "")
+ if not url:
+ raise ValueError("Missing required parameter: url")
+ return _create_app_response(
+ "ui://mcp-ui/url",
+ {"url": url},
+ arguments.get("width", "100%"),
+ arguments.get("height", "auto"),
+ )
+
+
+def _handle_ask_user(arguments: Dict[str, Any]) -> Dict[str, Any]:
+ questions = arguments.get("questions", [])
+ if not questions:
+ raise ValueError("Missing required parameter: questions")
+ return _create_app_response(
+ "ui://mcp-ui/ask-user",
+ {"questions": questions},
+ "100%", "auto",
+ )
+
+
+TOOL_HANDLERS = {
+ "render_html": _handle_render_html,
+ "render_url": _handle_render_url,
+ "ask_user": _handle_ask_user,
+}
async def handle_request(request: Dict[str, Any]) -> Dict[str, Any]:
@@ -80,7 +140,21 @@ async def handle_request(request: Dict[str, Any]) -> Dict[str, Any]:
request_id = request.get("id")
if method == "initialize":
- return create_initialize_response(request_id, "mcp-ui")
+ return {
+ "jsonrpc": "2.0",
+ "id": request_id,
+ "result": {
+ "protocolVersion": "2024-11-05",
+ "capabilities": {
+ "tools": {},
+ "resources": {},
+ },
+ "serverInfo": {
+ "name": "mcp-ui",
+ "version": "3.0.0",
+ },
+ },
+ }
elif method == "ping":
return create_ping_response(request_id)
@@ -89,64 +163,67 @@ async def handle_request(request: Dict[str, Any]) -> Dict[str, Any]:
tools = load_tools_from_json("mcp_ui_tools.json")
if not tools:
tools = [
- {
- "name": "render_ui",
- "description": "Render an interactive UI resource in the chat.",
- "inputSchema": {
- "type": "object",
- "properties": {
- "uri": {"type": "string"},
- "title": {"type": "string"},
- "html_content": {"type": "string"},
- "url": {"type": "string"},
- },
- "required": ["uri", "title"],
- },
- }
+ {"name": "render_html", "description": "Render custom HTML.",
+ "inputSchema": {"type": "object", "properties": {"title": {"type": "string"}, "html_content": {"type": "string"}}, "required": ["title", "html_content"]},
+ "_meta": {"ui": {"resourceUri": "ui://mcp-ui/html"}}},
+ {"name": "render_url", "description": "Embed external URL.",
+ "inputSchema": {"type": "object", "properties": {"title": {"type": "string"}, "url": {"type": "string"}}, "required": ["title", "url"]},
+ "_meta": {"ui": {"resourceUri": "ui://mcp-ui/url"}}},
+ {"name": "ask_user", "description": "Present questions with options.",
+ "inputSchema": {"type": "object", "properties": {"title": {"type": "string"}, "questions": {"type": "array"}}, "required": ["title", "questions"]},
+ "_meta": {"ui": {"resourceUri": "ui://mcp-ui/ask-user"}}},
]
return create_tools_list_response(request_id, tools)
+ elif method == "resources/list":
+ return {
+ "jsonrpc": "2.0",
+ "id": request_id,
+ "result": {"resources": RESOURCE_DEFINITIONS},
+ }
+
+ elif method == "resources/read":
+ uri = params.get("uri", "")
+ try:
+ html = _load_app_html(uri)
+ except (ValueError, FileNotFoundError) as e:
+ return create_error_response(request_id, -32602, str(e))
+ mime = RESOURCE_MIME_TYPE
+ for rd in RESOURCE_DEFINITIONS:
+ if rd["uri"] == uri:
+ mime = rd["mimeType"]
+ break
+ return {
+ "jsonrpc": "2.0",
+ "id": request_id,
+ "result": {
+ "contents": [
+ {
+ "uri": uri,
+ "mimeType": mime,
+ "text": html,
+ }
+ ]
+ },
+ }
+
elif method == "tools/call":
tool_name = params.get("name")
arguments = params.get("arguments", {})
- if tool_name == "render_ui":
- uri = arguments.get("uri", "")
- data = arguments.get("data", {})
+ handler = TOOL_HANDLERS.get(tool_name)
+ if not handler:
+ return create_error_response(request_id, -32601, f"Unknown tool: {tool_name}")
- if not uri or not uri.startswith("ui://mcp-ui/"):
- return create_error_response(
- request_id, -32602,
- "Invalid uri. Must be one of: ui://mcp-ui/html, ui://mcp-ui/url, ui://mcp-ui/ask-user"
- )
+ try:
+ result = handler(arguments)
+ except ValueError as e:
+ return create_error_response(request_id, -32602, str(e))
- path = uri.replace("ui://mcp-ui/", "")
-
- if path == "html" and not data.get("html_content"):
- return create_error_response(
- request_id, -32602, "Missing required parameter: data.html_content (for uri=ui://mcp-ui/html)"
- )
- elif path == "url" and not data.get("url"):
- return create_error_response(
- request_id, -32602, "Missing required parameter: data.url (for uri=ui://mcp-ui/url)"
- )
- elif path == "ask-user" and not data.get("questions"):
- return create_error_response(
- request_id, -32602, "Missing required parameter: data.questions (for uri=ui://mcp-ui/ask-user)"
- )
-
- result = _handle_render_ui(uri, data)
- return {"jsonrpc": "2.0", "id": request_id, "result": result}
-
- else:
- return create_error_response(
- request_id, -32601, f"Unknown tool: {tool_name}"
- )
+ return {"jsonrpc": "2.0", "id": request_id, "result": result}
else:
- return create_error_response(
- request_id, -32601, f"Unknown method: {method}"
- )
+ return create_error_response(request_id, -32601, f"Unknown method: {method}")
except Exception as e:
return create_error_response(
@@ -155,7 +232,6 @@ async def handle_request(request: Dict[str, Any]) -> Dict[str, Any]:
async def main():
- """Main entry point."""
await handle_mcp_streaming(handle_request)