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)