Compare commits

..

No commits in common. "5ed28123b4ddc3c49bf9622806e366fe362a5a94" and "495b8031bbb27d28722480a9d16f8559548988e9" have entirely different histories.

18 changed files with 880 additions and 1405 deletions

View File

@ -2,8 +2,7 @@
> 负责范围:技能包管理服务 - 核心实现 > 负责范围:技能包管理服务 - 核心实现
> 最后更新2026-04-20
> 最后更新2026-05-20
## 当前状态 ## 当前状态
@ -11,23 +10,17 @@ Skill 系统支持两种来源:官方 skills (`./skills/`) 和用户 skills (`
目前已新增一批**纯 `SKILL.md` 型业务 skill MVP**,用于研究、摘要、报告和情报编排,底层文件处理与外部检索能力继续复用既有 skill。 目前已新增一批**纯 `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 - `routes/skill_manager.py` - Skill 上传/删除/列表 API
- `agent/plugin_hook_loader.py` - Hook 系统实现 - `agent/plugin_hook_loader.py` - Hook 系统实现
- `agent/deep_assistant.py` - `CustomSkillsMiddleware` - `agent/deep_assistant.py` - `CustomSkillsMiddleware`
- `agent/prompt_loader.py` - PrePrompt hooks + MCP 配置合并 - `agent/prompt_loader.py` - PrePrompt hooks + MCP 配置合并
- `routes/mcp_resources.py` - MCP App 静态 HTML resource REST 入口
- `skills/` - 官方 skills 目录 - `skills/` - 官方 skills 目录
- `skills_developing/` - 开发中 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-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: 环境变量 `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` 残留 - 2026-04-19: `create_robot_project` 的 autoload 去重和 stale 清理补强autoload 目录也纳入 managed 清理,避免 `rag-retrieve-only` 场景下旧的 `rag-retrieve` 残留
@ -39,17 +32,12 @@ MCP UI 类 skill 已按 MCP Apps 模式改造:工具返回数据,静态 HTML
## Gotchas开发必读 ## 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/_metaMCP App payload 需作为 text JSON 传递给前端识别
- ⚠️ 纯 `SKILL.md` 型业务 skill 适合先承载 workflow、输入模板、输出模板需要稳定文件产出或自动化时再补 `scripts/` - ⚠️ 纯 `SKILL.md` 型业务 skill 适合先承载 workflow、输入模板、输出模板需要稳定文件产出或自动化时再补 `scripts/`
- ⚠️ 新业务 skill 应复用既有基础能力 skill`baidu-search`、`xlsx`、`docx`、`pdf`、`schedule-job`、`imap-smtp-email`),避免重复定义底层工具能力 - ⚠️ 新业务 skill 应复用既有基础能力 skill`baidu-search`、`xlsx`、`docx`、`pdf`、`schedule-job`、`imap-smtp-email`),避免重复定义底层工具能力
- ⚠️ 新增脚本优先采用 `Python + argparse + JSON stdout`,比 `argv[1] JSON` 更适合自动化链路 - ⚠️ 新增脚本优先采用 `Python + argparse + JSON stdout`,比 `argv[1] JSON` 更适合自动化链路
- ⚠️ `auto-daily-summary` 需要特别注意中文分句、action 边界截断、risk 窗口裁剪,否则容易把整句/整段吞进去 - ⚠️ `auto-daily-summary` 需要特别注意中文分句、action 边界截断、risk 窗口裁剪,否则容易把整句/整段吞进去
- ⚠️ `competitor-news-intel` 的 payload 校验应按命令拆分collect/analyze/run不要共用一套最小校验 - ⚠️ `competitor-news-intel` 的 payload 校验应按命令拆分collect/analyze/run不要共用一套最小校验
- ⚠️ `competitor-news-intel``collect/run` 依赖 `BAIDU_API_KEY`;无该环境变量时应返回稳定错误 JSON不要静默降级 - ⚠️ `competitor-news-intel``collect/run` 依赖 `BAIDU_API_KEY`;无该环境变量时应返回稳定错误 JSON不要静默降级
- ⚠️ `create_robot_project` 的 autoload 去重是“包含匹配”,只要传入的 skill 字符串里包含 autoload skill 名,就不会重复自动加载 - ⚠️ `create_robot_project` 的 autoload 去重是“包含匹配”,只要传入的 skill 字符串里包含 autoload skill 名,就不会重复自动加载
- ⚠️ `_extract_skills_to_robot` 只会从 `skills/{PROJECT_NAME}` 读取官方 skills默认是 `common` - ⚠️ `_extract_skills_to_robot` 只会从 `skills/{PROJECT_NAME}` 读取官方 skills默认是 `common`

View File

@ -1,11 +1,5 @@
# 2026-Q2 Skill Changelog # 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 ### 2026-04-20
- **新增**: `skills/autoload/onprem/rag-retrieve/hooks/retrieval-policy-forbidden-self-knowledge.md` - **新增**: `skills/autoload/onprem/rag-retrieve/hooks/retrieval-policy-forbidden-self-knowledge.md`
- **说明**: 基于现有 `retrieval-policy.md` 衍生出更严格的检索策略,明确禁止在知识问答场景中使用模型自身知识补全答案,要求回答只能来自检索证据 - **说明**: 基于现有 `retrieval-policy.md` 衍生出更严格的检索策略,明确禁止在知识问答场景中使用模型自身知识补全答案,要求回答只能来自检索证据

View File

@ -80,9 +80,8 @@ from utils.log_util.logger import init_with_fastapi
# Initialize logger # Initialize logger
logger = logging.getLogger('app') 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 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 from routes.webdav import wsgidav_app
@ -227,7 +226,6 @@ app.include_router(bot_manager.router)
app.include_router(payment.router) app.include_router(payment.router)
app.include_router(memory.router) app.include_router(memory.router)
# 注册语音对话路由 # 注册语音对话路由
app.include_router(voice.router) app.include_router(voice.router)
@ -237,11 +235,8 @@ app.include_router(file_manager_router)
# 注册知识库API路由 # 注册知识库API路由
app.include_router(knowledge_base.router, prefix="/api/v1/knowledge-base", tags=["knowledge-base"]) app.include_router(knowledge_base.router, prefix="/api/v1/knowledge-base", tags=["knowledge-base"])
# 挂载 WsgiDAVWSGI 应用通过 WSGIMiddleware 集成到 ASGI # 挂载 WsgiDAVWSGI 应用通过 WSGIMiddleware 集成到 ASGI
# MCP App resources endpoint
app.include_router(mcp_resources_router)
# Register the file management API routes # Register the file management API routes
app.include_router(file_manager_router) app.include_router(file_manager_router)

View File

@ -47,6 +47,7 @@ dependencies = [
"daytona-sdk", "daytona-sdk",
"langchain-daytona", "langchain-daytona",
"langfuse (>=2.0.0,<4.0.0)", "langfuse (>=2.0.0,<4.0.0)",
"mcp-ui-server (>=1.0.0,<2.0.0)",
] ]
[tool.poetry.requires-plugins] [tool.poetry.requires-plugins]

View File

@ -143,11 +143,11 @@ async def enhanced_generate_stream_response(
elif isinstance(msg, ToolMessage) and msg.content: elif isinstance(msg, ToolMessage) and msg.content:
message_tag = "TOOL_RESPONSE" message_tag = "TOOL_RESPONSE"
waiting_for_answer_first_char = False waiting_for_answer_first_char = False
# Always output MCP App responses even when tool_response is disabled # Always output UIResource responses even when tool_response is disabled
is_ui_resource = ( is_ui_resource = (
msg.text msg.text
and msg.text.lstrip().startswith('{"') and msg.text.lstrip().startswith('{"')
and '"type":"app"' in msg.text and '"ui://' in msg.text
) )
if config.tool_response or is_ui_resource: if config.tool_response or is_ui_resource:
new_content = f"[{message_tag}] {msg.name}\n{msg.text}\n" new_content = f"[{message_tag}] {msg.name}\n{msg.text}\n"

View File

@ -1,77 +0,0 @@
"""
REST endpoint for serving MCP App resources.
Maps ui:// URIs to static HTML App files under the bot's skill directory.
GET /api/v1/mcp/resources?uri=ui://data-dashboard/chart&bot_id=xxx
-> projects/robot/{bot_id}/skills/data-dashboard/apps/chart.html
"""
import logging
from pathlib import Path
from fastapi import APIRouter, Query, HTTPException
from fastapi.responses import HTMLResponse
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/mcp", tags=["mcp-resources"])
PROJECT_ROOT = Path(__file__).parent.parent
# In-memory cache: (bot_id, uri) -> html string
_resource_cache: dict[tuple[str, str], str] = {}
def _resolve_uri_to_path(uri: str, bot_id: str) -> Path:
"""Resolve a ui:// URI to a local file path.
URI format: ui://{server-name}/{resource-name}
File path: projects/robot/{bot_id}/skills/{server-name}/apps/{resource-name}.html
"""
if not uri.startswith("ui://"):
raise ValueError(f"Invalid URI scheme: {uri}")
rest = uri[5:] # Remove "ui://"
parts = rest.split("/", 1)
if len(parts) != 2 or not parts[0] or not parts[1]:
raise ValueError(f"Invalid URI format: {uri}")
server_name, resource_name = parts
# Prevent path traversal
if ".." in server_name or "/" in server_name:
raise ValueError(f"Invalid server name: {server_name}")
safe_name = Path(resource_name).name
if safe_name != resource_name:
raise ValueError(f"Invalid resource name: {resource_name}")
return PROJECT_ROOT / "projects" / "robot" / bot_id / "skills" / server_name / "apps" / f"{resource_name}.html"
@router.get("/resources")
async def get_resource(
uri: str = Query(..., description="Resource URI (e.g. ui://data-dashboard/chart)"),
bot_id: str = Query(..., description="Bot ID"),
):
"""Fetch an MCP App HTML resource by URI and bot_id.
Returns the static HTML App that should be loaded into an iframe.
The host sends tool result data to the iframe via postMessage.
"""
cache_key = (bot_id, uri)
if cache_key in _resource_cache:
return HTMLResponse(content=_resource_cache[cache_key], media_type="text/html")
try:
file_path = _resolve_uri_to_path(uri, bot_id)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
if not file_path.is_file():
raise HTTPException(status_code=404, detail=f"Resource not found: {uri} (bot_id={bot_id})")
html = file_path.read_text(encoding="utf-8")
_resource_cache[cache_key] = html
return HTMLResponse(content=html, media_type="text/html")

View File

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

View File

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

View File

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

View File

@ -1,165 +1,458 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Data Dashboard MCP Server - standard MCP Apps protocol (SEP-1865). 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.
- 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 asyncio
import json import json
import os from typing import Any, Dict, List
from typing import Any, Dict
from mcp_ui_server import create_ui_resource, UIMetadataKey
from mcp_common import ( from mcp_common import (
create_error_response, create_error_response,
create_initialize_response,
create_ping_response, create_ping_response,
create_tools_list_response, create_tools_list_response,
load_tools_from_json, load_tools_from_json,
handle_mcp_streaming, handle_mcp_streaming,
) )
RESOURCE_MIME_TYPE = "text/html;profile=mcp-app"
APPS_DIR = os.path.join(os.path.dirname(__file__), "apps")
# Resource URI -> static HTML App file mapping def _build_dashboard_html(title: str, metrics: List[Dict[str, str]]) -> str:
RESOURCE_MAP = { """Build a self-contained HTML dashboard from metrics data."""
"ui://data-dashboard/metrics": "metrics.html", cards_html = ""
"ui://data-dashboard/chart": "chart.html", for m in metrics:
"ui://data-dashboard/multi-chart": "multi-chart.html", label = _esc(m.get("label", ""))
value = _esc(m.get("value", ""))
change = m.get("change", "")
change_type = m.get("change_type", "neutral")
change_html = ""
if change:
color = "#16a34a" if change_type == "up" else "#dc2626" if change_type == "down" else "#6b7280"
arrow = "&#9650;" if change_type == "up" else "&#9660;" if change_type == "down" else "&#8212;"
change_html = f'<span class="change" style="color:{color}"><span class="arrow">{arrow}</span> {_esc(change)}</span>'
cards_html += f"""
<div class="card">
<div class="card-label">{label}</div>
<div class="card-value">{value}</div>
{f'<div class="card-change">{change_html}</div>' if change_html else ''}
</div>"""
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{_esc(title)}</title>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f8fafc; padding: 24px; }}
h1 {{ font-size: 20px; font-weight: 600; color: #0f172a; margin-bottom: 20px; }}
.grid {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; }}
.card {{ background: #fff; border: 1px solid #e2e8f0; border-radius: 12px; padding: 20px; transition: box-shadow 0.15s; }}
.card:hover {{ box-shadow: 0 4px 12px rgba(0,0,0,0.06); }}
.card-label {{ font-size: 13px; color: #64748b; margin-bottom: 8px; }}
.card-value {{ font-size: 28px; font-weight: 700; color: #0f172a; }}
.card-change {{ margin-top: 8px; font-size: 13px; font-weight: 500; }}
.arrow {{ font-size: 10px; }}
</style>
</head>
<body>
<h1>{_esc(title)}</h1>
<div class="grid">{cards_html}
</div>
</body>
</html>"""
def _esc(text: str) -> str:
"""Minimal HTML escaping."""
return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
# ---------------------------------------------------------------------------
# Chart HTML builder (ECharts)
# ---------------------------------------------------------------------------
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"],
} }
RESOURCE_DEFINITIONS = [
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"] = [
{ {
"uri": "ui://data-dashboard/metrics", "name": s["name"],
"name": "metrics-dashboard", "type": "line",
"title": "Metrics Dashboard", "data": s["data"],
"description": "Renders KPI metric cards", "smooth": smooth,
"mimeType": RESOURCE_MIME_TYPE, "stack": "total" if stacked else None,
}, "areaStyle": {} if stacked else None,
{ "label": _label_cfg(show_label),
"uri": "ui://data-dashboard/chart", }
"name": "chart", for s in series_data
"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,
},
] ]
return opt
def _load_app_html(uri: str) -> str: def _option_bar(title, categories, series_data, stacked=False, smooth=False, show_label=False):
"""Load static HTML App file for the given resource URI.""" opt = _base_option(title)
filename = RESOURCE_MAP.get(uri) opt["xAxis"] = {"type": "category", "data": categories}
if not filename: opt["yAxis"] = {"type": "value"}
raise ValueError(f"Unknown resource URI: {uri}") opt["series"] = [
filepath = os.path.join(APPS_DIR, filename)
with open(filepath, "r", encoding="utf-8") as f:
return f.read()
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
"""
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}]}
# ---------------------------------------------------------------------------
# Tool handlers — return data only, no HTML generation
# ---------------------------------------------------------------------------
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",
)
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")
width = arguments.get("width", "100%")
height = arguments.get("height", "400px")
return _create_app_response(
"ui://data-dashboard/chart",
{ {
"title": title, "name": s["name"],
"chart_type": chart_type, "type": "bar",
"data": chart_data, "data": s["data"],
"theme": arguments.get("theme", "light"), "stack": "total" if stacked else None,
"stacked": arguments.get("stacked", False), "label": _label_cfg(show_label),
"smooth": arguments.get("smooth", False), "barMaxWidth": 50,
"show_label": arguments.get("show_label", False), }
}, for s in series_data
width, height, ]
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 _handle_render_multi_chart(arguments: Dict[str, Any]) -> Dict[str, Any]: def _create_ui_resource(uri: str, html: str, width: str = "100%", height: str = "auto") -> Dict[str, Any]:
title = arguments.get("title", "Dashboard") """Create a self-contained UIResource with HTML in resource.text."""
charts = arguments.get("charts", []) 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.
"""
title = data.get("title", "Dashboard")
theme = data.get("theme", "light")
path = uri.replace("ui://data-dashboard/", "")
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")
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)
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: if not charts:
raise ValueError("Missing required parameter: charts") raise ValueError("Missing required parameter: data.charts")
for i, c in enumerate(charts): for i, c in enumerate(charts):
if not c.get("chart_type"): if not c.get("chart_type"):
raise ValueError(f"charts[{i}]: missing chart_type") raise ValueError(f"data.charts[{i}]: missing chart_type")
if not c.get("data", {}).get("series"): if not c.get("data", {}).get("series"):
raise ValueError(f"charts[{i}]: missing data.series") raise ValueError(f"data.charts[{i}]: missing data.series")
columns = arguments.get("columns", 2) columns = data.get("columns", 2)
html = _build_multi_chart_html(title, charts, columns=columns, theme=theme)
num_rows = (len(charts) + columns - 1) // columns num_rows = (len(charts) + columns - 1) // columns
total_height = f"{80 + num_rows * 420}px" total_height = f"{80 + num_rows * 420}px"
return _create_ui_resource(uri, html, "100%", total_height)
return _create_app_response( else:
"ui://data-dashboard/multi-chart", raise ValueError(f"Unknown URI path: {path}")
{
"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]: async def handle_request(request: Dict[str, Any]) -> Dict[str, Any]:
@ -170,21 +463,7 @@ async def handle_request(request: Dict[str, Any]) -> Dict[str, Any]:
request_id = request.get("id") request_id = request.get("id")
if method == "initialize": if method == "initialize":
return { return create_initialize_response(request_id, "data-dashboard")
"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": elif method == "ping":
return create_ping_response(request_id) return create_ping_response(request_id)
@ -194,63 +473,49 @@ async def handle_request(request: Dict[str, Any]) -> Dict[str, Any]:
if not tools: if not tools:
tools = [ tools = [
{ {
"name": name, "name": "render_data_viz",
"description": f"Render {name.replace('render_', '')}", "description": "Render data visualization. Use uri to specify type.",
"inputSchema": {"type": "object", "properties": {}, "required": []}, "inputSchema": {
"_meta": {"ui": {"resourceUri": uri}}, "type": "object",
} "properties": {
for name, uri in [ "uri": {"type": "string"},
("render_metrics", "ui://data-dashboard/metrics"), "title": {"type": "string"},
("render_chart", "ui://data-dashboard/chart"), },
("render_multi_chart", "ui://data-dashboard/multi-chart"), "required": ["uri", "title"],
]
]
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,
}
]
}, },
} }
]
return create_tools_list_response(request_id, tools)
elif method == "tools/call": elif method == "tools/call":
tool_name = params.get("name") tool_name = params.get("name")
arguments = params.get("arguments", {}) arguments = params.get("arguments", {})
handler = TOOL_HANDLERS.get(tool_name) if tool_name == "render_data_viz":
if not handler: uri = arguments.get("uri", "")
return create_error_response(request_id, -32601, f"Unknown tool: {tool_name}") 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"
)
try: try:
result = handler(arguments) result = _handle_data_viz(uri, data)
except ValueError as e: except ValueError as e:
return create_error_response(request_id, -32602, str(e)) return create_error_response(request_id, -32602, str(e))
return {"jsonrpc": "2.0", "id": request_id, "result": result} return {"jsonrpc": "2.0", "id": request_id, "result": result}
else: else:
return create_error_response(request_id, -32601, f"Unknown method: {method}") return create_error_response(
request_id, -32601, f"Unknown tool: {tool_name}"
)
else:
return create_error_response(
request_id, -32601, f"Unknown method: {method}"
)
except Exception as e: except Exception as e:
return create_error_response( return create_error_response(
@ -259,6 +524,7 @@ async def handle_request(request: Dict[str, Any]) -> Dict[str, Any]:
async def main(): async def main():
"""Main entry point."""
await handle_mcp_streaming(handle_request) await handle_mcp_streaming(handle_request)

View File

@ -1,17 +1,26 @@
[ [
{ {
"name": "render_metrics", "name": "render_data_viz",
"description": "Render a metric card dashboard showing KPIs, stats, and numerical summaries.", "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?}",
"inputSchema": { "inputSchema": {
"type": "object", "type": "object",
"properties": {
"uri": {
"type": "string",
"enum": ["ui://data-dashboard/metrics", "ui://data-dashboard/chart", "ui://data-dashboard/multi-chart"],
"description": "Resource URI that determines the visualization type"
},
"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": { "properties": {
"title": { "title": {
"type": "string", "type": "string",
"description": "Title displayed at the top of the dashboard" "description": "Title displayed at the top of the visualization"
}, },
"metrics": { "metrics": {
"type": "array", "type": "array",
"description": "Array of metric objects to display as cards", "description": "[uri=metrics] Array of metric objects to display as cards",
"items": { "items": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -22,34 +31,15 @@
}, },
"required": ["label", "value"] "required": ["label", "value"]
} }
}
},
"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": { "chart_type": {
"type": "string", "type": "string",
"enum": ["line", "bar", "pie", "radar", "scatter", "gauge"], "enum": ["line", "bar", "pie", "radar", "scatter", "gauge"],
"description": "Type of chart to render" "description": "[uri=chart] Type of chart to render"
}, },
"data": { "data": {
"type": "object", "type": "object",
"description": "Chart data object", "description": "[uri=chart] Chart data object",
"properties": { "properties": {
"categories": { "categories": {
"type": "array", "type": "array",
@ -73,27 +63,27 @@
}, },
"width": { "width": {
"type": "string", "type": "string",
"description": "CSS width. Default: '100%'", "description": "[uri=chart] CSS width. Default: '100%'",
"default": "100%" "default": "100%"
}, },
"height": { "height": {
"type": "string", "type": "string",
"description": "CSS height. Default: '400px'", "description": "[uri=chart] CSS height. Default: '400px'",
"default": "400px" "default": "400px"
}, },
"stacked": { "stacked": {
"type": "boolean", "type": "boolean",
"description": "Stack series (line/bar). Default: false", "description": "[uri=chart] Stack series (line/bar). Default: false",
"default": false "default": false
}, },
"smooth": { "smooth": {
"type": "boolean", "type": "boolean",
"description": "Smooth curves (line). Default: false", "description": "[uri=chart] Smooth curves (line). Default: false",
"default": false "default": false
}, },
"show_label": { "show_label": {
"type": "boolean", "type": "boolean",
"description": "Show data labels. Default: false", "description": "[uri=chart] Show data labels. Default: false",
"default": false "default": false
}, },
"theme": { "theme": {
@ -101,29 +91,17 @@
"enum": ["light", "dark"], "enum": ["light", "dark"],
"description": "Color theme. Default: 'light'", "description": "Color theme. Default: 'light'",
"default": "light" "default": "light"
}
}, },
"required": ["title", "chart_type", "data"] "columns": {
}, "type": "integer",
"_meta": { "description": "[uri=multi-chart] Grid columns (1-4). Default: 2",
"ui": { "default": 2,
"resourceUri": "ui://data-dashboard/chart" "minimum": 1,
} "maximum": 4
}
},
{
"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": { "charts": {
"type": "array", "type": "array",
"description": "Array of chart objects", "description": "[uri=multi-chart] Array of chart objects",
"items": { "items": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -155,27 +133,11 @@
}, },
"required": ["title", "chart_type", "data"] "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"] "required": ["uri", "data"]
},
"_meta": {
"ui": {
"resourceUri": "ui://data-dashboard/multi-chart"
}
} }
} }
] ]

View File

@ -1,72 +1,85 @@
## Data Visualization Tools Usage Guide ## `render_data_viz` usage guide
Three tools are available for rendering data visualizations: This tool renders data visualization resources. Use the `uri` field to specify the visualization type, and pass all parameters in the `data` object.
- `render_metrics` — Metric card dashboard (KPIs, stats)
- `render_chart` — Single chart (line, bar, pie, radar, scatter, gauge) ### Supported URIs:
- `render_multi_chart` — Multiple charts in a grid layout - `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
--- ---
### 1. render_metrics ### 1. Metrics — `ui://data-dashboard/metrics`
When to use: KPIs, stats, numerical summaries, side-by-side comparisons. When to use: KPIs, stats, numerical summaries, side-by-side comparisons.
``` ```
render_metrics( render_data_viz(
title="Sales Overview", uri="ui://data-dashboard/metrics",
metrics=[ data={
"title": "Sales Overview",
"metrics": [
{"label": "Revenue", "value": "$12,345", "change": "+12.5%", "change_type": "up"}, {"label": "Revenue", "value": "$12,345", "change": "+12.5%", "change_type": "up"},
{"label": "Users", "value": "1,234", "change": "-3.2%", "change_type": "down"}, {"label": "Users", "value": "1,234", "change": "-3.2%", "change_type": "down"},
{"label": "Conversion", "value": "4.5%", "change_type": "neutral"} {"label": "Conversion", "value": "4.5%", "change_type": "neutral"}
] ]
}
) )
``` ```
--- ---
### 2. render_chart ### 2. Chart — `ui://data-dashboard/chart`
When to use: trends, comparisons, distributions, compositions, single metric gauges. When to use: trends, comparisons, distributions, compositions, single metric gauges.
#### Line chart — trends over time #### Line chart — trends over time
``` ```
render_chart( render_data_viz(
title="Monthly Revenue", uri="ui://data-dashboard/chart",
chart_type="line",
data={ data={
"title": "Monthly Revenue",
"chart_type": "line",
"data": {
"categories": ["Jan", "Feb", "Mar", "Apr", "May", "Jun"], "categories": ["Jan", "Feb", "Mar", "Apr", "May", "Jun"],
"series": [ "series": [
{"name": "2024", "data": [820, 932, 901, 934, 1290, 1330]}, {"name": "2024", "data": [820, 932, 901, 934, 1290, 1330]},
{"name": "2025", "data": [620, 732, 801, 1034, 1190, 1530]} {"name": "2025", "data": [620, 732, 801, 1034, 1190, 1530]}
] ]
}, },
smooth=true "smooth": true
}
) )
``` ```
#### Bar chart — comparisons #### Bar chart — comparisons
``` ```
render_chart( render_data_viz(
title="Sales by Region", uri="ui://data-dashboard/chart",
chart_type="bar",
data={ data={
"title": "Sales by Region",
"chart_type": "bar",
"data": {
"categories": ["North", "South", "East", "West"], "categories": ["North", "South", "East", "West"],
"series": [ "series": [
{"name": "Q1", "data": [320, 302, 341, 374]}, {"name": "Q1", "data": [320, 302, 341, 374]},
{"name": "Q2", "data": [220, 182, 191, 234]} {"name": "Q2", "data": [220, 182, 191, 234]}
] ]
}, },
stacked=true, "stacked": true,
show_label=true "show_label": true
}
) )
``` ```
#### Pie chart — proportions #### Pie chart — proportions
``` ```
render_chart( render_data_viz(
title="Traffic Sources", uri="ui://data-dashboard/chart",
chart_type="pie",
data={ data={
"title": "Traffic Sources",
"chart_type": "pie",
"data": {
"series": [{ "series": [{
"name": "Sources", "name": "Sources",
"data": [ "data": [
@ -77,48 +90,58 @@ render_chart(
] ]
}] }]
} }
}
) )
``` ```
#### Radar chart — multi-dimensional #### Radar chart — multi-dimensional
``` ```
render_chart( render_data_viz(
title="Skill Assessment", uri="ui://data-dashboard/chart",
chart_type="radar",
data={ data={
"title": "Skill Assessment",
"chart_type": "radar",
"data": {
"categories": ["Sales", "Admin", "Tech", "Support", "Marketing"], "categories": ["Sales", "Admin", "Tech", "Support", "Marketing"],
"series": [ "series": [
{"name": "Alice", "data": [4200, 3000, 20000, 35000, 50000]}, {"name": "Alice", "data": [4200, 3000, 20000, 35000, 50000]},
{"name": "Bob", "data": [5000, 14000, 28000, 26000, 42000]} {"name": "Bob", "data": [5000, 14000, 28000, 26000, 42000]}
] ]
} }
}
) )
``` ```
#### Scatter chart — correlation #### Scatter chart — correlation
``` ```
render_chart( render_data_viz(
title="Height vs Weight", uri="ui://data-dashboard/chart",
chart_type="scatter",
data={ data={
"title": "Height vs Weight",
"chart_type": "scatter",
"data": {
"series": [ "series": [
{"name": "Male", "data": [[161, 51], [167, 59], [159, 49], [175, 73]]}, {"name": "Male", "data": [[161, 51], [167, 59], [159, 49], [175, 73]]},
{"name": "Female", "data": [[150, 45], [160, 55], [165, 60], [155, 50]]} {"name": "Female", "data": [[150, 45], [160, 55], [165, 60], [155, 50]]}
] ]
} }
}
) )
``` ```
#### Gauge chart — single KPI #### Gauge chart — single KPI
``` ```
render_chart( render_data_viz(
title="CPU Usage", uri="ui://data-dashboard/chart",
chart_type="gauge", data={
data={"series": [{"name": "CPU", "data": [72.5]}]} "title": "CPU Usage",
"chart_type": "gauge",
"data": {"series": [{"name": "CPU", "data": [72.5]}]}
}
) )
``` ```
#### Chart options: #### Chart options (inside data):
- `width`: CSS width, default "100%" - `width`: CSS width, default "100%"
- `height`: CSS height, default "400px" - `height`: CSS height, default "400px"
- `theme`: "light" (default) or "dark" - `theme`: "light" (default) or "dark"
@ -128,16 +151,18 @@ render_chart(
--- ---
### 3. render_multi_chart ### 3. Multi-chart — `ui://data-dashboard/multi-chart`
When to use: comprehensive overviews, multiple related charts, business reports. When to use: comprehensive overviews, multiple related charts, business reports.
``` ```
render_multi_chart( render_data_viz(
title="Monthly Business Report", uri="ui://data-dashboard/multi-chart",
columns=2, data={
theme="light", "title": "Monthly Business Report",
charts=[ "columns": 2,
"theme": "light",
"charts": [
{ {
"title": "Revenue Trend", "title": "Revenue Trend",
"chart_type": "line", "chart_type": "line",
@ -176,6 +201,7 @@ render_multi_chart(
"height": "300px" "height": "300px"
} }
] ]
}
) )
``` ```

View File

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

View File

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

View File

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

View File

@ -17,11 +17,14 @@ 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. Do NOT call ask_user with empty options arrays.
### How to call ask_user ### How to call render_ui for ask-user
Use `uri` + `data` format:
```json ```json
ask_user( render_ui(
title="A descriptive title", uri="ui://mcp-ui/ask-user",
questions=[ data={
"title": "A descriptive title",
"questions": [
{ {
"question": "Who is the audience?", "question": "Who is the audience?",
"options": ["Leadership", "Team", "Client"] "options": ["Leadership", "Team", "Client"]
@ -31,6 +34,7 @@ ask_user(
"options": ["5-10 minutes", "15-20 minutes", "30+ minutes"] "options": ["5-10 minutes", "15-20 minutes", "30+ minutes"]
} }
] ]
}
) )
``` ```

View File

@ -1,9 +1,18 @@
[ [
{ {
"name": "render_html", "name": "render_ui",
"description": "Render custom HTML/CSS/JS content in the chat as an interactive UI widget.", "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?}",
"inputSchema": { "inputSchema": {
"type": "object", "type": "object",
"properties": {
"uri": {
"type": "string",
"enum": ["ui://mcp-ui/html", "ui://mcp-ui/url", "ui://mcp-ui/ask-user"],
"description": "Resource URI that determines the rendering mode"
},
"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": { "properties": {
"title": { "title": {
"type": "string", "type": "string",
@ -11,73 +20,25 @@
}, },
"html_content": { "html_content": {
"type": "string", "type": "string",
"description": "Complete HTML content to render. Can include inline CSS and JavaScript." "description": "[uri=html] 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": { "url": {
"type": "string", "type": "string",
"description": "External URL to embed in an iframe" "description": "[uri=url] External URL to embed in an iframe."
}, },
"width": { "width": {
"type": "string", "type": "string",
"description": "CSS width. Default: '100%'", "description": "[uri=html|url] CSS width. Default: '100%'",
"default": "100%" "default": "100%"
}, },
"height": { "height": {
"type": "string", "type": "string",
"description": "CSS height. Default: 'auto'", "description": "[uri=html|url] CSS height. Default: 'auto'",
"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": { "questions": {
"type": "array", "type": "array",
"description": "Array of questions to ask the user", "description": "[uri=ask-user] Array of questions to ask the user.",
"items": { "items": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -89,24 +50,21 @@
"type": "array", "type": "array",
"minItems": 2, "minItems": 2,
"items": { "type": "string" }, "items": { "type": "string" },
"description": "REQUIRED array with at least 2 options" "description": "REQUIRED array with at least 2 options."
}, },
"multi_select": { "multi_select": {
"type": "boolean", "type": "boolean",
"description": "If true, the user can select multiple options. Default: false", "description": "If true, the user can select multiple options. Default: false.",
"default": false "default": false
} }
}, },
"required": ["question", "options"] "required": ["question", "options"]
} }
} }
},
"required": ["title", "questions"]
},
"_meta": {
"ui": {
"resourceUri": "ui://mcp-ui/ask-user"
} }
} }
},
"required": ["uri", "data"]
}
} }
] ]

View File

@ -1,135 +1,75 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
MCP UI Server - standard MCP Apps protocol (SEP-1865). MCP UI Server - provides interactive UI rendering tools.
Uses URI-based routing: ui://mcp-ui/[resource-type]
- 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 asyncio
import json import json
import os import sys
from typing import Any, Dict from typing import Any, Dict
from mcp_ui_server import create_ui_resource, UIMetadataKey
from mcp_common import ( from mcp_common import (
create_error_response, create_error_response,
create_initialize_response,
create_ping_response, create_ping_response,
create_tools_list_response, create_tools_list_response,
load_tools_from_json, load_tools_from_json,
handle_mcp_streaming, 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")
# Resource URI -> static HTML App file mapping def _serialize_ui_resource(ui_resource) -> str:
RESOURCE_MAP = { """Serialize a UIResource to JSON string."""
"ui://mcp-ui/html": "html.html", return json.dumps(ui_resource.model_dump(mode="json"), ensure_ascii=False)
"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 _load_app_html(uri: str) -> str: def _handle_render_ui(uri: str, data: Dict[str, Any]) -> Dict[str, Any]:
"""Load static HTML App file for the given resource URI.""" """Unified handler for all render_ui URIs.
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
def _create_app_response(resource_uri: str, data: Dict[str, Any], the frontend only needs TOOL_RESPONSE to render, no TOOL_CALL reference needed.
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
""" """
app_json = json.dumps({ width = data.get("width", "100%")
"type": "app", height = data.get("height", "auto")
"resourceUri": resource_uri,
"data": data, if uri == "ui://mcp-ui/ask-user":
"_meta": { questions = data.get("questions", [])
"mcpui.dev/ui-preferred-frame-size": [width, height], resource = create_ui_resource({
"uri": uri,
"content": {"type": "rawHtml", "htmlString": json.dumps({"questions": questions}, ensure_ascii=False)},
"encoding": "text",
"uiMetadata": {
"interactive": True,
}, },
}, ensure_ascii=False) })
return {"content": [{"type": "text", "text": app_json}]} 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]: async def handle_request(request: Dict[str, Any]) -> Dict[str, Any]:
@ -140,21 +80,7 @@ async def handle_request(request: Dict[str, Any]) -> Dict[str, Any]:
request_id = request.get("id") request_id = request.get("id")
if method == "initialize": if method == "initialize":
return { return create_initialize_response(request_id, "mcp-ui")
"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": elif method == "ping":
return create_ping_response(request_id) return create_ping_response(request_id)
@ -163,67 +89,64 @@ async def handle_request(request: Dict[str, Any]) -> Dict[str, Any]:
tools = load_tools_from_json("mcp_ui_tools.json") tools = load_tools_from_json("mcp_ui_tools.json")
if not tools: if not tools:
tools = [ tools = [
{"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, "name": "render_ui",
"mimeType": mime, "description": "Render an interactive UI resource in the chat.",
"text": html, "inputSchema": {
} "type": "object",
] "properties": {
"uri": {"type": "string"},
"title": {"type": "string"},
"html_content": {"type": "string"},
"url": {"type": "string"},
},
"required": ["uri", "title"],
}, },
} }
]
return create_tools_list_response(request_id, tools)
elif method == "tools/call": elif method == "tools/call":
tool_name = params.get("name") tool_name = params.get("name")
arguments = params.get("arguments", {}) arguments = params.get("arguments", {})
handler = TOOL_HANDLERS.get(tool_name) if tool_name == "render_ui":
if not handler: uri = arguments.get("uri", "")
return create_error_response(request_id, -32601, f"Unknown tool: {tool_name}") data = arguments.get("data", {})
try: if not uri or not uri.startswith("ui://mcp-ui/"):
result = handler(arguments) return create_error_response(
except ValueError as e: request_id, -32602,
return create_error_response(request_id, -32602, str(e)) "Invalid uri. Must be one of: ui://mcp-ui/html, ui://mcp-ui/url, ui://mcp-ui/ask-user"
)
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} return {"jsonrpc": "2.0", "id": request_id, "result": result}
else: else:
return create_error_response(request_id, -32601, f"Unknown method: {method}") return create_error_response(
request_id, -32601, f"Unknown tool: {tool_name}"
)
else:
return create_error_response(
request_id, -32601, f"Unknown method: {method}"
)
except Exception as e: except Exception as e:
return create_error_response( return create_error_response(
@ -232,6 +155,7 @@ async def handle_request(request: Dict[str, Any]) -> Dict[str, Any]:
async def main(): async def main():
"""Main entry point."""
await handle_mcp_streaming(handle_request) await handle_mcp_streaming(handle_request)