188 lines
6.8 KiB
Python
188 lines
6.8 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Data Dashboard MCP Server - renders metric data as an interactive dashboard.
|
|
Returns UIResource via the mcp-ui protocol so the frontend renders it as an iframe.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
from typing import Any, Dict, List
|
|
|
|
from mcp_ui_server import create_ui_resource, UIMetadataKey
|
|
|
|
from mcp_common import (
|
|
create_error_response,
|
|
create_initialize_response,
|
|
create_ping_response,
|
|
create_tools_list_response,
|
|
load_tools_from_json,
|
|
handle_mcp_streaming,
|
|
)
|
|
|
|
|
|
def _build_dashboard_html(title: str, metrics: List[Dict[str, str]]) -> str:
|
|
"""Build a self-contained HTML dashboard from metrics data."""
|
|
cards_html = ""
|
|
for m in metrics:
|
|
label = _esc(m.get("label", ""))
|
|
value = _esc(m.get("value", ""))
|
|
change = m.get("change", "")
|
|
change_type = m.get("change_type", "neutral")
|
|
|
|
change_html = ""
|
|
if change:
|
|
color = "#16a34a" if change_type == "up" else "#dc2626" if change_type == "down" else "#6b7280"
|
|
arrow = "▲" if change_type == "up" else "▼" if change_type == "down" else "—"
|
|
change_html = f'<span class="change" style="color:{color}"><span class="arrow">{arrow}</span> {_esc(change)}</span>'
|
|
|
|
cards_html += f"""
|
|
<div class="card">
|
|
<div class="card-label">{label}</div>
|
|
<div class="card-value">{value}</div>
|
|
{f'<div class="card-change">{change_html}</div>' if change_html else ''}
|
|
</div>"""
|
|
|
|
return f"""<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>{_esc(title)}</title>
|
|
<style>
|
|
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
|
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f8fafc; padding: 24px; }}
|
|
h1 {{ font-size: 20px; font-weight: 600; color: #0f172a; margin-bottom: 20px; }}
|
|
.grid {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; }}
|
|
.card {{ background: #fff; border: 1px solid #e2e8f0; border-radius: 12px; padding: 20px; transition: box-shadow 0.15s; }}
|
|
.card:hover {{ box-shadow: 0 4px 12px rgba(0,0,0,0.06); }}
|
|
.card-label {{ font-size: 13px; color: #64748b; margin-bottom: 8px; }}
|
|
.card-value {{ font-size: 28px; font-weight: 700; color: #0f172a; }}
|
|
.card-change {{ margin-top: 8px; font-size: 13px; font-weight: 500; }}
|
|
.arrow {{ font-size: 10px; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>{_esc(title)}</h1>
|
|
<div class="grid">{cards_html}
|
|
</div>
|
|
</body>
|
|
</html>"""
|
|
|
|
|
|
def _esc(text: str) -> str:
|
|
"""Minimal HTML escaping."""
|
|
return text.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
|
|
|
|
|
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]
|
|
|
|
ui_resource = create_ui_resource(
|
|
{
|
|
"uri": f"ui://data-dashboard/{uri_slug}",
|
|
"content": {"type": "rawHtml", "htmlString": html},
|
|
"encoding": "text",
|
|
"uiMetadata": {
|
|
UIMetadataKey.PREFERRED_FRAME_SIZE: ["100%", "auto"],
|
|
},
|
|
}
|
|
)
|
|
|
|
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)}"}]
|
|
}
|
|
|
|
|
|
async def handle_request(request: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Handle an MCP request."""
|
|
try:
|
|
method = request.get("method")
|
|
params = request.get("params", {})
|
|
request_id = request.get("id")
|
|
|
|
if method == "initialize":
|
|
return create_initialize_response(request_id, "data-dashboard")
|
|
|
|
elif method == "ping":
|
|
return create_ping_response(request_id)
|
|
|
|
elif method == "tools/list":
|
|
tools = load_tools_from_json("dashboard_tools.json")
|
|
if not tools:
|
|
tools = [
|
|
{
|
|
"name": "render_dashboard",
|
|
"description": "Render a data dashboard with metric cards.",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"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"],
|
|
},
|
|
}
|
|
]
|
|
return create_tools_list_response(request_id, tools)
|
|
|
|
elif method == "tools/call":
|
|
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:
|
|
return create_error_response(
|
|
request_id, -32602, "Missing required parameter: metrics"
|
|
)
|
|
|
|
result = render_dashboard(title, metrics)
|
|
return {"jsonrpc": "2.0", "id": request_id, "result": result}
|
|
|
|
else:
|
|
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:
|
|
return create_error_response(
|
|
request.get("id"), -32603, f"Internal error: {str(e)}"
|
|
)
|
|
|
|
|
|
async def main():
|
|
"""Main entry point."""
|
|
await handle_mcp_streaming(handle_request)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
asyncio.run(main())
|