Merge branch 'feature/mcp-ui' into dev

This commit is contained in:
朱潮 2026-05-20 14:58:19 +08:00
commit 46cf0933a0
18 changed files with 1452 additions and 896 deletions

View File

@ -1,23 +1,37 @@
# Skill 功能
> 负责范围:技能包管理服务 - 核心实现
<<<<<<< Updated upstream
> 最后更新2026-04-20
=======
> 最后更新2026-05-20
>>>>>>> Stashed changes
## 当前状态
Skill 系统支持两种来源:官方 skills (`./skills/`) 和用户 skills (`projects/uploads/{bot_id}/skills/`)。支持 Hook 系统和 MCP 服务器配置,通过 SKILL.md 或 plugin.json 定义元数据。
<<<<<<< Updated upstream
=======
MCP UI 类 skill 已按 MCP Apps 模式改造:工具返回数据,静态 HTML App 由 host 加载后通过 postMessage 接收数据渲染。
目前已新增一批**纯 `SKILL.md` 型业务 skill MVP**,用于研究、摘要、报告和情报编排,底层文件处理与外部检索能力继续复用既有 skill。
>>>>>>> Stashed changes
## 核心文件
- `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` 残留
@ -27,6 +41,18 @@ Skill 系统支持两种来源:官方 skills (`./skills/`) 和用户 skills (`
## Gotchas开发必读
<<<<<<< Updated upstream
=======
- ⚠️ 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/_metaMCP 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不要静默降级
>>>>>>> Stashed changes
- ⚠️ `create_robot_project` 的 autoload 去重是“包含匹配”,只要传入的 skill 字符串里包含 autoload skill 名,就不会重复自动加载
- ⚠️ `_extract_skills_to_robot` 只会从 `skills/{PROJECT_NAME}` 读取官方 skills默认是 `common`
- ⚠️ 执行脚本必须使用绝对路径

View File

@ -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` 衍生出更严格的检索策略,明确禁止在知识问答场景中使用模型自身知识补全答案,要求回答只能来自检索证据

View File

@ -81,7 +81,12 @@ from utils.log_util.logger import init_with_fastapi
logger = logging.getLogger('app')
# Import route modules
<<<<<<< Updated upstream
from routes import chat, files, projects, system, skill_manager, database, memory
=======
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
>>>>>>> Stashed changes
from routes.webdav import wsgidav_app
@ -210,6 +215,23 @@ app.include_router(skill_manager.router)
app.include_router(database.router)
app.include_router(memory.router)
<<<<<<< Updated upstream
=======
# 注册语音对话路由
app.include_router(voice.router)
# 注册文件管理API路由
app.include_router(file_manager_router)
# 注册知识库API路由
app.include_router(knowledge_base.router, prefix="/api/v1/knowledge-base", tags=["knowledge-base"])
# MCP App resources endpoint
app.include_router(mcp_resources_router)
# 挂载 WsgiDAVWSGI 应用通过 WSGIMiddleware 集成到 ASGI
>>>>>>> Stashed changes
# Register the file management API routes
app.include_router(file_manager_router)

View File

@ -42,7 +42,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]

View File

@ -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"

77
routes/mcp_resources.py Normal file
View File

@ -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")

View File

@ -0,0 +1,148 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chart</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 16px; }
#chart { width: 100%; height: 100%; min-height: 360px; }
</style>
</head>
<body>
<div id="chart"></div>
<script>
(function () {
var chartInstance = null;
var COLOR_PALETTE = ['#5470c6','#91cc75','#fac858','#ee6666','#73c0de','#3ba272','#fc8452','#9a60b4'];
function baseOption(title) {
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: COLOR_PALETTE
};
}
function labelCfg(show) { return { show: show, position: 'top', fontSize: 11 }; }
var builders = {
line: function (d) {
var opt = baseOption(d.title);
opt.xAxis = { type: 'category', data: d.data.categories || [], boundaryGap: false };
opt.yAxis = { type: 'value' };
opt.series = (d.data.series || []).map(function (s) {
return { name: s.name, type: 'line', data: s.data, smooth: !!d.smooth,
stack: d.stacked ? 'total' : null, areaStyle: d.stacked ? {} : null,
label: labelCfg(!!d.show_label) };
});
return opt;
},
bar: function (d) {
var opt = baseOption(d.title);
opt.xAxis = { type: 'category', data: d.data.categories || [] };
opt.yAxis = { type: 'value' };
opt.series = (d.data.series || []).map(function (s) {
return { name: s.name, type: 'bar', data: s.data, stack: d.stacked ? 'total' : null,
label: labelCfg(!!d.show_label), barMaxWidth: 50 };
});
return opt;
},
pie: function (d) {
var opt = baseOption(d.title);
delete opt.grid;
opt.tooltip = { trigger: 'item', formatter: '{b}: {c} ({d}%)' };
var s0 = (d.data.series || [])[0] || {};
opt.series = [{
name: s0.name || d.title, type: 'pie', radius: ['40%','70%'], center: ['50%','55%'],
data: s0.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;
},
radar: function (d) {
var opt = baseOption(d.title);
delete opt.grid;
opt.tooltip = { trigger: 'item' };
var cats = d.data.categories || [];
var series = d.data.series || [];
var maxVals = cats.map(function () { return 0; });
series.forEach(function (s) {
s.data.forEach(function (v, i) { if (i < maxVals.length && v > maxVals[i]) maxVals[i] = v; });
});
opt.radar = {
indicator: cats.map(function (c, i) { return { name: c, max: Math.round(maxVals[i] * 1.2) || 100 }; }),
center: ['50%','55%']
};
opt.series = [{
type: 'radar',
data: series.map(function (s) { return { value: s.data, name: s.name }; }),
areaStyle: { opacity: 0.15 }
}];
return opt;
},
scatter: function (d) {
var opt = baseOption(d.title);
opt.tooltip = { trigger: 'item', formatter: '{a}: ({c})' };
opt.xAxis = { type: 'value', scale: true };
opt.yAxis = { type: 'value', scale: true };
opt.series = (d.data.series || []).map(function (s) {
return { name: s.name, type: 'scatter', data: s.data, symbolSize: 10, label: labelCfg(!!d.show_label) };
});
return opt;
},
gauge: function (d) {
var opt = baseOption(d.title);
delete opt.grid;
opt.tooltip = { trigger: 'item' };
var s0 = (d.data.series || [])[0] || {};
var val = (s0.data && s0.data[0]) || 0;
if (typeof val !== 'number') val = 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: val, name: s0.name || d.title }],
axisLine: { lineStyle: { width: 20 } },
progress: { show: true, width: 20 },
pointer: { show: true }
}];
return opt;
}
};
function render(payload) {
var theme = payload.theme === 'dark' ? 'dark' : null;
var bgColor = payload.theme === 'dark' ? '#1a1a2e' : '#f8fafc';
document.body.style.background = bgColor;
var el = document.getElementById('chart');
if (chartInstance) chartInstance.dispose();
chartInstance = echarts.init(el, theme);
var builder = builders[payload.chart_type] || builders.line;
chartInstance.setOption(builder(payload));
window.addEventListener('resize', function () { chartInstance && chartInstance.resize(); });
}
/* MCP Apps postMessage protocol */
window.addEventListener('message', function (event) {
var msg = event.data;
if (msg && msg.type === 'mcp-app-data') {
render(msg.payload);
}
});
/* Signal readiness to host */
window.parent.postMessage({ type: 'mcp-app-ready' }, '*');
})();
</script>
</body>
</html>

View File

@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Metrics</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f8fafc; padding: 24px; }
h1 { font-size: 20px; font-weight: 600; color: #0f172a; margin-bottom: 20px; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; }
.card { background: #fff; border: 1px solid #e2e8f0; border-radius: 12px; padding: 20px; transition: box-shadow 0.15s; }
.card:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.06); }
.card-label { font-size: 13px; color: #64748b; margin-bottom: 8px; }
.card-value { font-size: 28px; font-weight: 700; color: #0f172a; }
.card-change { margin-top: 8px; font-size: 13px; font-weight: 500; }
.arrow { font-size: 10px; }
</style>
</head>
<body>
<h1 id="title"></h1>
<div class="grid" id="grid"></div>
<script>
(function () {
function esc(t) { var d = document.createElement('div'); d.textContent = t; return d.innerHTML; }
function render(payload) {
document.getElementById('title').textContent = payload.title || 'Dashboard';
var grid = document.getElementById('grid');
grid.innerHTML = '';
(payload.metrics || []).forEach(function (m) {
var changeHtml = '';
if (m.change) {
var ct = m.change_type || 'neutral';
var color = ct === 'up' ? '#16a34a' : ct === 'down' ? '#dc2626' : '#6b7280';
var arrow = ct === 'up' ? '\u25B2' : ct === 'down' ? '\u25BC' : '\u2014';
changeHtml = '<div class="card-change"><span class="arrow" style="color:' + color + '">' + arrow + '</span> <span style="color:' + color + '">' + esc(m.change) + '</span></div>';
}
var card = document.createElement('div');
card.className = 'card';
card.innerHTML = '<div class="card-label">' + esc(m.label || '') + '</div>'
+ '<div class="card-value">' + esc(m.value || '') + '</div>'
+ changeHtml;
grid.appendChild(card);
});
}
window.addEventListener('message', function (event) {
var msg = event.data;
if (msg && msg.type === 'mcp-app-data') {
render(msg.payload);
}
});
window.parent.postMessage({ type: 'mcp-app-ready' }, '*');
})();
</script>
</body>
</html>

View File

@ -0,0 +1,178 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Multi Chart</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 24px; }
h1 { font-size: 22px; font-weight: 600; margin-bottom: 20px; text-align: center; }
.grid { display: grid; gap: 16px; }
.chart-card { border-radius: 12px; padding: 16px; border: 1px solid #e2e8f0; }
@media (max-width: 768px) { .grid { grid-template-columns: 1fr !important; } }
</style>
</head>
<body>
<h1 id="title"></h1>
<div class="grid" id="grid"></div>
<script>
(function () {
var charts = [];
var COLOR_PALETTE = ['#5470c6','#91cc75','#fac858','#ee6666','#73c0de','#3ba272','#fc8452','#9a60b4'];
function baseOption(title) {
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: COLOR_PALETTE
};
}
function labelCfg(show) { return { show: show, position: 'top', fontSize: 11 }; }
var builders = {
line: function (d) {
var opt = baseOption(d.title);
opt.xAxis = { type: 'category', data: d.data.categories || [], boundaryGap: false };
opt.yAxis = { type: 'value' };
opt.series = (d.data.series || []).map(function (s) {
return { name: s.name, type: 'line', data: s.data, smooth: !!d.smooth,
stack: d.stacked ? 'total' : null, areaStyle: d.stacked ? {} : null,
label: labelCfg(!!d.show_label) };
});
return opt;
},
bar: function (d) {
var opt = baseOption(d.title);
opt.xAxis = { type: 'category', data: d.data.categories || [] };
opt.yAxis = { type: 'value' };
opt.series = (d.data.series || []).map(function (s) {
return { name: s.name, type: 'bar', data: s.data, stack: d.stacked ? 'total' : null,
label: labelCfg(!!d.show_label), barMaxWidth: 50 };
});
return opt;
},
pie: function (d) {
var opt = baseOption(d.title);
delete opt.grid;
opt.tooltip = { trigger: 'item', formatter: '{b}: {c} ({d}%)' };
var s0 = (d.data.series || [])[0] || {};
opt.series = [{
name: s0.name || d.title, type: 'pie', radius: ['40%','70%'], center: ['50%','55%'],
data: s0.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;
},
radar: function (d) {
var opt = baseOption(d.title);
delete opt.grid;
opt.tooltip = { trigger: 'item' };
var cats = d.data.categories || [];
var series = d.data.series || [];
var maxVals = cats.map(function () { return 0; });
series.forEach(function (s) {
s.data.forEach(function (v, i) { if (i < maxVals.length && v > maxVals[i]) maxVals[i] = v; });
});
opt.radar = {
indicator: cats.map(function (c, i) { return { name: c, max: Math.round(maxVals[i] * 1.2) || 100 }; }),
center: ['50%','55%']
};
opt.series = [{
type: 'radar',
data: series.map(function (s) { return { value: s.data, name: s.name }; }),
areaStyle: { opacity: 0.15 }
}];
return opt;
},
scatter: function (d) {
var opt = baseOption(d.title);
opt.tooltip = { trigger: 'item', formatter: '{a}: ({c})' };
opt.xAxis = { type: 'value', scale: true };
opt.yAxis = { type: 'value', scale: true };
opt.series = (d.data.series || []).map(function (s) {
return { name: s.name, type: 'scatter', data: s.data, symbolSize: 10, label: labelCfg(!!d.show_label) };
});
return opt;
},
gauge: function (d) {
var opt = baseOption(d.title);
delete opt.grid;
opt.tooltip = { trigger: 'item' };
var s0 = (d.data.series || [])[0] || {};
var val = (s0.data && s0.data[0]) || 0;
if (typeof val !== 'number') val = 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: val, name: s0.name || d.title }],
axisLine: { lineStyle: { width: 20 } },
progress: { show: true, width: 20 },
pointer: { show: true }
}];
return opt;
}
};
function render(payload) {
var isDark = payload.theme === 'dark';
document.body.style.background = isDark ? '#1a1a2e' : '#f8fafc';
document.body.style.color = isDark ? '#e0e0e0' : '#0f172a';
document.getElementById('title').textContent = payload.title || 'Dashboard';
var grid = document.getElementById('grid');
var columns = payload.columns || 2;
grid.style.gridTemplateColumns = 'repeat(' + columns + ', 1fr)';
grid.innerHTML = '';
// Dispose previous charts
charts.forEach(function (c) { c.dispose(); });
charts = [];
var echartsTheme = isDark ? 'dark' : null;
var cardBg = isDark ? '#252547' : '#ffffff';
var borderColor = isDark ? '#3a3a5c' : '#e2e8f0';
(payload.charts || []).forEach(function (chartDef, idx) {
var card = document.createElement('div');
card.className = 'chart-card';
card.style.background = cardBg;
card.style.borderColor = borderColor;
var el = document.createElement('div');
el.id = 'chart_' + idx;
el.style.width = '100%';
el.style.height = chartDef.height || '350px';
card.appendChild(el);
grid.appendChild(card);
var c = echarts.init(el, echartsTheme);
var builder = builders[chartDef.chart_type] || builders.line;
c.setOption(builder(chartDef));
charts.push(c);
});
window.addEventListener('resize', function () {
charts.forEach(function (c) { c.resize(); });
});
}
window.addEventListener('message', function (event) {
var msg = event.data;
if (msg && msg.type === 'mcp-app-data') {
render(msg.payload);
}
});
window.parent.postMessage({ type: 'mcp-app-ready' }, '*');
})();
</script>
</body>
</html>

View File

@ -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 = "&#9650;" if change_type == "up" else "&#9660;" if change_type == "down" else "&#8212;"
change_html = f'<span class="change" style="color:{color}"><span class="arrow">{arrow}</span> {_esc(change)}</span>'
cards_html += f"""
<div class="card">
<div class="card-label">{label}</div>
<div class="card-value">{value}</div>
{f'<div class="card-change">{change_html}</div>' if change_html else ''}
</div>"""
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{_esc(title)}</title>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f8fafc; padding: 24px; }}
h1 {{ font-size: 20px; font-weight: 600; color: #0f172a; margin-bottom: 20px; }}
.grid {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; }}
.card {{ background: #fff; border: 1px solid #e2e8f0; border-radius: 12px; padding: 20px; transition: box-shadow 0.15s; }}
.card:hover {{ box-shadow: 0 4px 12px rgba(0,0,0,0.06); }}
.card-label {{ font-size: 13px; color: #64748b; margin-bottom: 8px; }}
.card-value {{ font-size: 28px; font-weight: 700; color: #0f172a; }}
.card-change {{ margin-top: 8px; font-size: 13px; font-weight: 500; }}
.arrow {{ font-size: 10px; }}
</style>
</head>
<body>
<h1>{_esc(title)}</h1>
<div class="grid">{cards_html}
</div>
</body>
</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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
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"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{_esc(title)}</title>
<script src="{ECHARTS_CDN}"></script>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: {bg_color}; padding: 16px; }}
#chart {{ width: {width}; height: {height}; }}
</style>
</head>
<body>
<div id="chart"></div>
<script>
var chart = echarts.init(document.getElementById('chart'), {echarts_theme});
var option = {option_json};
chart.setOption(option);
window.addEventListener('resize', function() {{ chart.resize(); }});
</script>
</body>
</html>"""
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"""
<div class="chart-card">
<div id="{chart_id}" style="width:100%;height:{chart_height};"></div>
</div>"""
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"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{_esc(title)}</title>
<script src="{ECHARTS_CDN}"></script>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: {bg_color}; padding: 24px; color: {text_color}; }}
h1 {{ font-size: 22px; font-weight: 600; margin-bottom: 20px; text-align: center; }}
.grid {{ display: grid; grid-template-columns: repeat({columns}, 1fr); gap: 16px; }}
.chart-card {{ background: {card_bg}; border: 1px solid {border_color}; border-radius: 12px; padding: 16px; }}
@media (max-width: 768px) {{ .grid {{ grid-template-columns: 1fr; }} }}
</style>
</head>
<body>
<h1>{_esc(title)}</h1>
<div class="grid">{chart_divs}
</div>
<script>
var charts = [];{chart_scripts}
window.addEventListener('resize', function() {{ charts.forEach(function(c) {{ c.resize(); }}); }});
</script>
</body>
</html>"""
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)

View File

@ -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"
}
}
}
]

View File

@ -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"
}
]
)
```

View File

@ -0,0 +1,103 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ask User</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f8fafc; padding: 20px; }
.question-block { margin-bottom: 20px; }
.question-text { font-size: 15px; font-weight: 600; color: #0f172a; margin-bottom: 10px; }
.options { display: flex; flex-wrap: wrap; gap: 8px; }
.option-btn { padding: 8px 16px; border: 1px solid #cbd5e1; border-radius: 8px; background: #fff;
cursor: pointer; font-size: 14px; color: #334155; transition: all 0.15s; }
.option-btn:hover { border-color: #3b82f6; color: #3b82f6; background: #eff6ff; }
.option-btn.selected { border-color: #3b82f6; background: #3b82f6; color: #fff; }
.submit-row { margin-top: 16px; text-align: right; }
.submit-btn { padding: 10px 24px; border: none; border-radius: 8px; background: #3b82f6;
color: #fff; font-size: 14px; font-weight: 600; cursor: pointer; }
.submit-btn:hover { background: #2563eb; }
.submit-btn:disabled { background: #94a3b8; cursor: not-allowed; }
</style>
</head>
<body>
<div id="root"></div>
<script>
(function () {
var selections = {};
function esc(t) { var d = document.createElement('div'); d.textContent = t; return d.innerHTML; }
function render(payload) {
var root = document.getElementById('root');
root.innerHTML = '';
selections = {};
var questions = payload.questions || [];
questions.forEach(function (q, qIdx) {
var block = document.createElement('div');
block.className = 'question-block';
var text = document.createElement('div');
text.className = 'question-text';
text.textContent = q.question;
block.appendChild(text);
var optionsDiv = document.createElement('div');
optionsDiv.className = 'options';
var multiSelect = !!q.multi_select;
selections[qIdx] = multiSelect ? [] : null;
(q.options || []).forEach(function (opt, oIdx) {
var btn = document.createElement('button');
btn.className = 'option-btn';
btn.textContent = opt;
btn.addEventListener('click', function () {
if (multiSelect) {
var arr = selections[qIdx];
var idx = arr.indexOf(opt);
if (idx >= 0) { arr.splice(idx, 1); btn.classList.remove('selected'); }
else { arr.push(opt); btn.classList.add('selected'); }
} else {
selections[qIdx] = opt;
optionsDiv.querySelectorAll('.option-btn').forEach(function (b) { b.classList.remove('selected'); });
btn.classList.add('selected');
}
});
optionsDiv.appendChild(btn);
});
block.appendChild(optionsDiv);
root.appendChild(block);
});
var submitRow = document.createElement('div');
submitRow.className = 'submit-row';
var submitBtn = document.createElement('button');
submitBtn.className = 'submit-btn';
submitBtn.textContent = 'Submit';
submitBtn.addEventListener('click', function () {
var answers = {};
questions.forEach(function (q, qIdx) {
answers[q.question] = selections[qIdx];
});
window.parent.postMessage({ type: 'mcp-app-response', payload: answers }, '*');
});
submitRow.appendChild(submitBtn);
root.appendChild(submitRow);
}
window.addEventListener('message', function (event) {
var msg = event.data;
if (msg && msg.type === 'mcp-app-data') {
render(msg.payload);
}
});
window.parent.postMessage({ type: 'mcp-app-ready' }, '*');
})();
</script>
</body>
</html>

View File

@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HTML Renderer</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
</style>
</head>
<body>
<div id="root"></div>
<script>
(function () {
function render(payload) {
var root = document.getElementById('root');
root.innerHTML = payload.html_content || '';
// Execute inline scripts that were injected via innerHTML
var scripts = root.querySelectorAll('script');
scripts.forEach(function (oldScript) {
var newScript = document.createElement('script');
if (oldScript.src) {
newScript.src = oldScript.src;
} else {
newScript.textContent = oldScript.textContent;
}
oldScript.parentNode.replaceChild(newScript, oldScript);
});
}
window.addEventListener('message', function (event) {
var msg = event.data;
if (msg && msg.type === 'mcp-app-data') {
render(msg.payload);
}
});
window.parent.postMessage({ type: 'mcp-app-ready' }, '*');
})();
</script>
</body>
</html>

View File

@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>URL Embed</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body, html { width: 100%; height: 100%; overflow: hidden; }
iframe { width: 100%; height: 100%; border: none; }
</style>
</head>
<body>
<iframe id="frame" sandbox="allow-scripts allow-same-origin allow-forms"></iframe>
<script>
(function () {
function render(payload) {
var frame = document.getElementById('frame');
frame.src = payload.url || '';
}
window.addEventListener('message', function (event) {
var msg = event.data;
if (msg && msg.type === 'mcp-app-data') {
render(msg.payload);
}
});
window.parent.postMessage({ type: 'mcp-app-ready' }, '*');
})();
</script>
</body>
</html>

View File

@ -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"]
}
]
)
```

View File

@ -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"
}
}
}
]

View File

@ -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)