Compare commits

...

8 Commits

Author SHA1 Message Date
朱潮
495b8031bb Merge branch 'feature/mcp-ui' into bot_manager 2026-05-19 19:23:57 +08:00
朱潮
c87daabd31 修改mcp协议 2026-05-19 19:19:39 +08:00
朱潮
65f3fba9ea Merge branch 'feature/mcp-ui' into bot_manager 2026-05-19 18:54:46 +08:00
朱潮
7615bd36ca 修改mcp协议 2026-05-19 18:54:37 +08:00
朱潮
35ceca1af3 Merge branch 'feature/mcp-ui' into bot_manager 2026-05-19 17:46:26 +08:00
朱潮
2a243202c9 修改mcp协议 2026-05-19 17:46:20 +08:00
朱潮
ece8b5b7d2 Merge branch 'feature/mcp-ui' into bot_manager 2026-05-19 15:36:59 +08:00
朱潮
7f66279311 修改mcp-ui协议 2026-05-19 15:27:36 +08:00
7 changed files with 858 additions and 221 deletions

View File

@ -114,8 +114,7 @@ async def enhanced_generate_stream_response(
chunk_args = tool_call_chunk.get("args") if isinstance(tool_call_chunk, dict) else getattr(tool_call_chunk, "args", None)
if chunk_name:
current_tool_name = chunk_name
# Always output ask_user and render_ui tool calls even when tool_response is disabled
if config.tool_response or current_tool_name in ('ask_user', 'render_ui'):
if config.tool_response:
if chunk_name:
new_content = f"[{message_tag}] {chunk_name}\n"
if chunk_args:

View File

@ -74,32 +74,385 @@ def _esc(text: str) -> str:
return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
def render_dashboard(title: str, metrics: List[Dict[str, str]]) -> Dict[str, Any]:
"""Create a UIResource dashboard and serialize it as JSON for TOOL_RESPONSE."""
try:
html = _build_dashboard_html(title, metrics)
uri_slug = title.replace(" ", "-").lower()[:50]
# ---------------------------------------------------------------------------
# Chart HTML builder (ECharts)
# ---------------------------------------------------------------------------
ui_resource = create_ui_resource(
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"] = [
{
"uri": f"ui://data-dashboard/{uri_slug}",
"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: ["100%", "auto"],
UIMetadataKey.PREFERRED_FRAME_SIZE: [width, height],
},
}
)
resource_json = json.dumps(
ui_resource.model_dump(mode="json"), ensure_ascii=False
)
})
resource_json = json.dumps(ui_resource.model_dump(mode="json"), ensure_ascii=False)
return {"content": [{"type": "text", "text": resource_json}]}
except Exception as e:
return {
"content": [{"type": "text", "text": f"Error creating dashboard: {str(e)}"}]
}
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:
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")
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)
else:
raise ValueError(f"Unknown URI path: {path}")
async def handle_request(request: Dict[str, Any]) -> Dict[str, Any]:
@ -120,27 +473,15 @@ async def handle_request(request: Dict[str, Any]) -> Dict[str, Any]:
if not tools:
tools = [
{
"name": "render_dashboard",
"description": "Render a data dashboard with metric cards.",
"name": "render_data_viz",
"description": "Render data visualization. Use uri to specify type.",
"inputSchema": {
"type": "object",
"properties": {
"uri": {"type": "string"},
"title": {"type": "string"},
"metrics": {
"type": "array",
"items": {
"type": "object",
"properties": {
"label": {"type": "string"},
"value": {"type": "string"},
"change": {"type": "string"},
"change_type": {"type": "string", "enum": ["up", "down", "neutral"]},
},
"required": ["label", "value"],
},
},
},
"required": ["title", "metrics"],
"required": ["uri", "title"],
},
}
]
@ -150,16 +491,20 @@ async def handle_request(request: Dict[str, Any]) -> Dict[str, Any]:
tool_name = params.get("name")
arguments = params.get("arguments", {})
if tool_name == "render_dashboard":
title = arguments.get("title", "Dashboard")
metrics = arguments.get("metrics", [])
if not metrics:
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, "Missing required parameter: metrics"
request_id, -32602,
"Invalid uri. Must be one of: ui://data-dashboard/metrics, ui://data-dashboard/chart, ui://data-dashboard/multi-chart"
)
result = render_dashboard(title, metrics)
try:
result = _handle_data_viz(uri, data)
except ValueError as e:
return create_error_response(request_id, -32602, str(e))
return {"jsonrpc": "2.0", "id": request_id, "result": result}
else:

View File

@ -1,43 +1,143 @@
[
{
"name": "render_dashboard",
"description": "Render an interactive data dashboard with metric cards. Pass an array of metrics, each with label, value, and optional change/change_type fields. The dashboard is rendered as an HTML card UI.",
"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?}",
"inputSchema": {
"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": {
"title": {
"type": "string",
"description": "Dashboard title, e.g. 'Sales Overview'"
"description": "Title displayed at the top of the visualization"
},
"metrics": {
"type": "array",
"description": "Array of metric objects to display as cards",
"description": "[uri=metrics] Array of metric objects to display as cards",
"items": {
"type": "object",
"properties": {
"label": {
"type": "string",
"description": "Metric name, e.g. 'Revenue'"
},
"value": {
"type": "string",
"description": "Metric value, e.g. '$12,345'"
},
"change": {
"type": "string",
"description": "Change indicator, e.g. '+12.5%' or '-3.2%'"
},
"change_type": {
"type": "string",
"enum": ["up", "down", "neutral"],
"description": "Direction of change"
}
"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": ["title", "metrics"]
"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": ["uri", "data"]
}
}
]

View File

@ -1,13 +1,214 @@
## `render_dashboard` usage guide
## `render_data_viz` usage guide
### When to use render_dashboard:
- When the user wants to visualize data as metric cards (KPIs, stats, numbers)
- When summarizing numerical results (sales, traffic, performance metrics)
- When comparing multiple data points side by side
This tool renders data visualization resources. Use the `uri` field to specify the visualization type, and pass all parameters in the `data` object.
### How to call:
render_dashboard(title="Sales Overview", metrics=[
### 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
---
### 1. Metrics — `ui://data-dashboard/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"}
])
]
}
)
```
---
### 2. Chart — `ui://data-dashboard/chart`
When to use: trends, comparisons, distributions, compositions, single metric gauges.
#### Line chart — trends over time
```
render_data_viz(
uri="ui://data-dashboard/chart",
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
}
)
```
#### Bar chart — comparisons
```
render_data_viz(
uri="ui://data-dashboard/chart",
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
}
)
```
#### Pie chart — proportions
```
render_data_viz(
uri="ui://data-dashboard/chart",
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}
]
}]
}
}
)
```
#### Radar chart — multi-dimensional
```
render_data_viz(
uri="ui://data-dashboard/chart",
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]}
]
}
}
)
```
#### Scatter chart — correlation
```
render_data_viz(
uri="ui://data-dashboard/chart",
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]]}
]
}
}
)
```
#### 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]}]}
}
)
```
#### Chart options (inside data):
- `width`: CSS width, default "100%"
- `height`: CSS height, default "400px"
- `theme`: "light" (default) or "dark"
- `stacked`: stack series (line/bar), default false
- `smooth`: smooth curves (line), default false
- `show_label`: show data labels, default false
---
### 3. Multi-chart — `ui://data-dashboard/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
},
{
"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"
}
]
}
)
```
### Tips:
- Always choose the most appropriate chart_type for the data
- Use line for time-series trends, bar for category comparisons
- Use pie for proportions (limit ~7 segments), radar for multi-dimensional scores
- Use scatter for correlation, gauge for single KPI (0-100)
- Set height: "400px" for most charts, "300px" for gauge
- Multi-chart: columns=2 for 2-4 charts, keep 4-6 max per call

View File

@ -17,14 +17,13 @@ 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 format options
- Options MUST be placed in the `options` array field, NOT embedded in the question text.
- Keep the question text short and clean — just the question itself.
- Each question MUST have at least 2 options in the options array.
CORRECT example:
### How to call render_ui for ask-user
Use `uri` + `data` format:
```json
{
render_ui(
uri="ui://mcp-ui/ask-user",
data={
"title": "A descriptive title",
"questions": [
{
"question": "Who is the audience?",
@ -36,8 +35,14 @@ CORRECT example:
}
]
}
)
```
### How to format options
- Options MUST be placed in the `options` array field, NOT embedded in the question text.
- Keep the question text short and clean — just the question itself.
- Each question MUST have at least 2 options in the options array.
WRONG example (DO NOT do this):
```json
{

View File

@ -1,9 +1,18 @@
[
{
"name": "render_ui",
"description": "Render an interactive UI widget in the chat. Supports two modes: (1) raw HTML — provide html_content to render custom HTML/CSS/JS, (2) external URL — provide url to embed an external webpage in an iframe. Use html_content OR url, not both.",
"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": {
"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": {
"title": {
"type": "string",
@ -11,35 +20,25 @@
},
"html_content": {
"type": "string",
"description": "Complete HTML content to render. Can include inline CSS and JavaScript within <style> and <script> tags. Use this OR url, not both."
"description": "[uri=html] Complete HTML content to render. Can include inline CSS and JavaScript."
},
"url": {
"type": "string",
"description": "External URL to embed in an iframe. Use this OR html_content, not both."
"description": "[uri=url] External URL to embed in an iframe."
},
"width": {
"type": "string",
"description": "CSS width for the iframe. Default: '100%'",
"description": "[uri=html|url] CSS width. Default: '100%'",
"default": "100%"
},
"height": {
"type": "string",
"description": "CSS height for the iframe. Set to a fixed value (e.g. '300px', '600px') matching the HTML content height. Use 'auto' only when the HTML is responsive and has no fixed height. Default: 'auto'",
"description": "[uri=html|url] CSS height. Default: 'auto'",
"default": "auto"
}
},
"required": ["title", "html_content"]
}
},
{
"name": "ask_user",
"description": "Present questions with selectable options to the user. CRITICAL: Every question MUST have at least 2 options in the options array — generate reasonable suggestions based on context. Do NOT call this tool with empty options arrays. See the ask_user usage guide in system prompt for detailed rules.",
"inputSchema": {
"type": "object",
"properties": {
"questions": {
"type": "array",
"description": "Array of questions to ask the user. Each question is an object with its own question text, options, and multi_select setting.",
"description": "[uri=ask-user] Array of questions to ask the user.",
"items": {
"type": "object",
"properties": {
@ -50,22 +49,22 @@
"options": {
"type": "array",
"minItems": 2,
"items": {
"type": "string"
},
"description": "REQUIRED array with at least 2 options. Put choices here, NOT in the question text."
"items": { "type": "string" },
"description": "REQUIRED array with at least 2 options."
},
"multi_select": {
"type": "boolean",
"description": "If true, the user can select multiple options for this question. Default: false.",
"description": "If true, the user can select multiple options. Default: false.",
"default": false
}
},
"required": ["question", "options"]
}
}
}
}
},
"required": ["questions"]
"required": ["uri", "data"]
}
}
]

View File

@ -1,6 +1,7 @@
#!/usr/bin/env python3
"""
MCP UI Server - provides interactive UI rendering tools.
Uses URI-based routing: ui://mcp-ui/[resource-type]
"""
import asyncio
@ -25,59 +26,49 @@ def _serialize_ui_resource(ui_resource) -> str:
return json.dumps(ui_resource.model_dump(mode="json"), ensure_ascii=False)
def ask_user() -> Dict[str, Any]:
"""Return a UIResource response for ask_user tool.
def _handle_render_ui(uri: str, data: Dict[str, Any]) -> Dict[str, Any]:
"""Unified handler for all render_ui URIs.
The actual questions are in the TOOL_CALL arguments. The frontend
detects ui_type from the UIResource metadata and extracts content
from the corresponding TOOL_CALL args.
Content is self-contained in the TOOL_RESPONSE resource
the frontend only needs TOOL_RESPONSE to render, no TOOL_CALL reference needed.
"""
width = data.get("width", "100%")
height = data.get("height", "auto")
if uri == "ui://mcp-ui/ask-user":
questions = data.get("questions", [])
resource = create_ui_resource({
"uri": "ui://mcp-ui/ask-user",
"content": {"type": "rawHtml", "htmlString": "Questions sent to user."},
"uri": uri,
"content": {"type": "rawHtml", "htmlString": json.dumps({"questions": questions}, ensure_ascii=False)},
"encoding": "text",
"uiMetadata": {
"type": "ask_user",
"interactive": True,
},
})
return {"content": [{"type": "text", "text": _serialize_ui_resource(resource)}]}
def render_ui(arguments: Dict[str, Any]) -> Dict[str, Any]:
"""Return a UIResource response for render_ui tool.
The actual html_content/url is in the TOOL_CALL arguments. The frontend
detects ui_type from the UIResource metadata and extracts content
from the corresponding TOOL_CALL args.
"""
html_content = arguments.get("html_content", "")
url = arguments.get("url", "")
width = arguments.get("width", "100%")
height = arguments.get("height", "auto")
if html_content:
elif uri == "ui://mcp-ui/url":
url = data.get("url", "")
resource = create_ui_resource({
"uri": "ui://mcp-ui/render-ui",
"content": {"type": "rawHtml", "htmlString": "UI rendered."},
"encoding": "text",
"uiMetadata": {
UIMetadataKey.PREFERRED_FRAME_SIZE: [width, height],
"type": "render_ui_html",
"interactive": False,
},
})
else:
resource = create_ui_resource({
"uri": "ui://mcp-ui/render-ui",
"uri": uri,
"content": {"type": "externalUrl", "iframeUrl": url},
"encoding": "text",
"uiMetadata": {
UIMetadataKey.PREFERRED_FRAME_SIZE: [width, height],
"type": "render_ui_url",
"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)}]}
@ -100,24 +91,16 @@ async def handle_request(request: Dict[str, Any]) -> Dict[str, Any]:
tools = [
{
"name": "render_ui",
"description": "Render an interactive UI widget in the chat.",
"description": "Render an interactive UI resource in the chat.",
"inputSchema": {
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "A descriptive title for the UI widget",
"uri": {"type": "string"},
"title": {"type": "string"},
"html_content": {"type": "string"},
"url": {"type": "string"},
},
"html_content": {
"type": "string",
"description": "Complete HTML content to render. Use this OR url.",
},
"url": {
"type": "string",
"description": "External URL to embed. Use this OR html_content.",
},
},
"required": ["title"],
"required": ["uri", "title"],
},
}
]
@ -128,26 +111,31 @@ async def handle_request(request: Dict[str, Any]) -> Dict[str, Any]:
arguments = params.get("arguments", {})
if tool_name == "render_ui":
html_content = arguments.get("html_content", "")
url = arguments.get("url", "")
uri = arguments.get("uri", "")
data = arguments.get("data", {})
if not html_content and not url:
if not uri or not uri.startswith("ui://mcp-ui/"):
return create_error_response(
request_id, -32602, "Missing required parameter: html_content or url"
request_id, -32602,
"Invalid uri. Must be one of: ui://mcp-ui/html, ui://mcp-ui/url, ui://mcp-ui/ask-user"
)
result = render_ui(arguments)
return {"jsonrpc": "2.0", "id": request_id, "result": result}
path = uri.replace("ui://mcp-ui/", "")
elif tool_name == "ask_user":
questions = arguments.get("questions", [])
if not questions:
if path == "html" and not data.get("html_content"):
return create_error_response(
request_id, -32602, "Missing required parameter: questions"
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 = ask_user()
result = _handle_render_ui(uri, data)
return {"jsonrpc": "2.0", "id": request_id, "result": result}
else: