merge
This commit is contained in:
commit
5ed28123b4
@ -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`
|
||||
|
||||
@ -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` 衍生出更严格的检索策略,明确禁止在知识问答场景中使用模型自身知识补全答案,要求回答只能来自检索证据
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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
77
routes/mcp_resources.py
Normal 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")
|
||||
148
skills/common/data-dashboard/apps/chart.html
Normal file
148
skills/common/data-dashboard/apps/chart.html
Normal 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>
|
||||
60
skills/common/data-dashboard/apps/metrics.html
Normal file
60
skills/common/data-dashboard/apps/metrics.html
Normal 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>
|
||||
178
skills/common/data-dashboard/apps/multi-chart.html
Normal file
178
skills/common/data-dashboard/apps/multi-chart.html
Normal 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>
|
||||
@ -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'<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("&", "&").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"""<!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)
|
||||
|
||||
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@ -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"
|
||||
}
|
||||
]
|
||||
)
|
||||
```
|
||||
|
||||
|
||||
103
skills/common/mcp-ui/apps/ask-user.html
Normal file
103
skills/common/mcp-ui/apps/ask-user.html
Normal 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>
|
||||
44
skills/common/mcp-ui/apps/html.html
Normal file
44
skills/common/mcp-ui/apps/html.html
Normal 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>
|
||||
33
skills/common/mcp-ui/apps/url.html
Normal file
33
skills/common/mcp-ui/apps/url.html
Normal 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>
|
||||
@ -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"]
|
||||
}
|
||||
]
|
||||
)
|
||||
```
|
||||
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user