Skip to content

Commit 318564c

Browse files
committed
Add MCP App visualization server for analyzing-data skill
Adds a Prefab-based MCP App server that renders interactive charts and tables inline in Cursor when the user requests visual output from the analyzing-data skill. - New `viz_mcp.py` server with `render_chart` and `render_table` tools using Prefab UI (BarChart, LineChart, DataTable with tabs, search, sort, pagination) - Register `analytics-viz` MCP server in both `.claude-plugin/plugin.json` and `.cursor-plugin/plugin.json` - Update SKILL.md with visualization workflow guidance
1 parent 1c6cf77 commit 318564c

3 files changed

Lines changed: 255 additions & 0 deletions

File tree

.cursor-plugin/plugin.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,5 +36,15 @@
3636
}
3737
]
3838
}
39+
},
40+
"mcpServers": {
41+
"analytics-viz": {
42+
"command": "uv",
43+
"args": [
44+
"run",
45+
"./skills/analyzing-data/scripts/viz_mcp.py",
46+
"--stdio"
47+
]
48+
}
3949
}
4050
}

skills/analyzing-data/SKILL.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ Answer business questions by querying the data warehouse. The kernel auto-starts
4242
```
4343

4444
6. **Present findings** to user.
45+
7. **Optional visualization** — If the user asks for chart/table visuals and the `analytics-viz` MCP server is available:
46+
- Use `render_table(rows=[...], title="...")` for tabular output
47+
- Use `render_chart(rows=[...], x_key="...", y_key="...", chart_type="line|bar", title="...", top_n=<int>, sort_desc=<bool>)` for trend/comparison views
48+
- The UI has controls to filter/sort/switch chart type within loaded data. Users can ask in chat for a different row count.
4549

4650
## Kernel Functions
4751

@@ -105,3 +109,19 @@ uv run scripts/cli.py cache clear [--stale-only] # Clear
105109

106110
- [reference/discovery-warehouse.md](reference/discovery-warehouse.md) — Large table handling, warehouse exploration, INFORMATION_SCHEMA queries
107111
- [reference/common-patterns.md](reference/common-patterns.md) — SQL templates for trends, comparisons, top-N, distributions, cohorts
112+
113+
## MCP App Visualization (Optional)
114+
115+
When an interactive chart/table is more useful than plain text, use the visualization MCP tools after running SQL with this skill's CLI.
116+
117+
Example workflow:
118+
119+
```bash
120+
# 1) Run query and print JSON rows
121+
uv run scripts/cli.py exec "import json; df = run_sql('SELECT ...'); print(json.dumps(df.to_dicts(), default=str))"
122+
```
123+
124+
Then call MCP tool:
125+
126+
- `render_table(rows=<parsed JSON>, title="Query Results")`
127+
- `render_chart(rows=<parsed JSON>, x_key="<col>", y_key="<metric>", chart_type="line", title="Descriptive Title")`
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
#!/usr/bin/env python3
2+
# /// script
3+
# requires-python = ">=3.11"
4+
# dependencies = [
5+
# "fastmcp[apps]>=3.1.0",
6+
# "prefab-ui==0.10.0",
7+
# ]
8+
# ///
9+
"""Visualization-only MCP server for the analyzing-data skill.
10+
11+
This server is intentionally small:
12+
- Query execution and business logic stay in `scripts/cli.py`
13+
- This server only renders MCP App visualizations (table/chart)
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import sys
19+
from collections.abc import Mapping
20+
from typing import Any, Literal
21+
22+
from fastmcp import FastMCP
23+
from fastmcp.tools import ToolResult
24+
from prefab_ui.app import PrefabApp
25+
from prefab_ui.components import (
26+
Column,
27+
DataTable,
28+
DataTableColumn,
29+
Heading,
30+
Muted,
31+
Tab,
32+
Tabs,
33+
)
34+
from prefab_ui.components.charts import BarChart, ChartSeries, LineChart
35+
36+
MAX_ROWS = 500
37+
38+
mcp = FastMCP(
39+
"Analytics Visualization MCP",
40+
instructions=(
41+
"Visualization-only server for query result display. "
42+
"Use render_table for tabular results and render_chart for line/bar charts. "
43+
"Do not run SQL here."
44+
),
45+
)
46+
47+
48+
def _to_json_safe(value: Any) -> Any:
49+
"""Convert values into JSON-safe types for tool payloads."""
50+
if isinstance(value, (str, int, float, bool)) or value is None:
51+
return value
52+
if isinstance(value, Mapping):
53+
return {str(key): _to_json_safe(item) for key, item in value.items()}
54+
if isinstance(value, (list, tuple)):
55+
return [_to_json_safe(item) for item in value]
56+
return str(value)
57+
58+
59+
def _normalize_rows(rows: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], bool]:
60+
"""Normalize rows and enforce a max payload size."""
61+
if not isinstance(rows, list):
62+
raise ValueError("rows must be a list of objects")
63+
64+
normalized: list[dict[str, Any]] = []
65+
for row in rows[:MAX_ROWS]:
66+
if not isinstance(row, Mapping):
67+
raise ValueError("each row must be an object/dict")
68+
normalized.append(
69+
{str(key): _to_json_safe(value) for key, value in row.items()}
70+
)
71+
72+
return normalized, len(rows) > MAX_ROWS
73+
74+
75+
def _build_table_app(title: str, rows: list[dict[str, Any]]) -> PrefabApp:
76+
"""Build a Prefab DataTable app."""
77+
keys = list(dict.fromkeys(k for row in rows for k in row))
78+
columns = [DataTableColumn(key=k, header=k, sortable=True) for k in keys]
79+
80+
with Column(gap=4, css_class="p-6") as view:
81+
Heading(title)
82+
Muted(f"{len(rows)} rows")
83+
DataTable(
84+
columns=columns,
85+
rows=rows,
86+
searchable=True,
87+
paginated=True,
88+
page_size=20,
89+
)
90+
91+
return PrefabApp(view=view)
92+
93+
94+
def _build_chart_app(
95+
title: str,
96+
rows: list[dict[str, Any]],
97+
x_key: str,
98+
y_key: str,
99+
chart_type: str,
100+
top_n: int | None,
101+
sort_desc: bool,
102+
) -> PrefabApp:
103+
"""Build a Prefab chart app with tabs for chart and table views."""
104+
if sort_desc:
105+
rows = sorted(rows, key=lambda r: _numeric(r.get(y_key)), reverse=True)
106+
else:
107+
rows = sorted(rows, key=lambda r: _numeric(r.get(y_key)))
108+
109+
display_rows = rows[:top_n] if top_n and top_n > 0 else rows
110+
111+
Chart = BarChart if chart_type == "bar" else LineChart
112+
keys = list(dict.fromkeys(k for row in rows for k in row))
113+
columns = [DataTableColumn(key=k, header=k, sortable=True) for k in keys]
114+
115+
with Column(gap=4, css_class="p-6") as view:
116+
Heading(title)
117+
Muted(
118+
f"Showing {len(display_rows)} of {len(rows)} rows · Ask in chat for a different count"
119+
)
120+
with Tabs():
121+
with Tab("Chart"):
122+
Chart(
123+
data=display_rows,
124+
series=[ChartSeries(data_key=y_key, label=y_key)],
125+
x_axis=x_key,
126+
show_legend=True,
127+
)
128+
with Tab("Table"):
129+
DataTable(
130+
columns=columns,
131+
rows=display_rows,
132+
searchable=True,
133+
paginated=True,
134+
page_size=20,
135+
)
136+
137+
return PrefabApp(view=view)
138+
139+
140+
def _numeric(value: Any) -> float:
141+
"""Extract a numeric value for sorting, defaulting to -inf."""
142+
if isinstance(value, (int, float)):
143+
return float(value)
144+
if isinstance(value, str):
145+
try:
146+
return float(value.replace(",", "").replace("$", ""))
147+
except ValueError:
148+
pass
149+
return float("-inf")
150+
151+
152+
@mcp.tool(app=True)
153+
def render_table(
154+
rows: list[dict[str, Any]],
155+
title: str = "Query Results",
156+
) -> ToolResult:
157+
"""Render rows in an interactive table MCP App.
158+
159+
Args:
160+
rows: Array of row objects from a query
161+
title: Table title
162+
"""
163+
normalized, truncated = _normalize_rows(rows)
164+
summary = f"{title}: {len(normalized)} rows"
165+
if truncated:
166+
summary += f" (truncated from {len(rows)})"
167+
return ToolResult(
168+
content=summary,
169+
structured_content=_build_table_app(title, normalized),
170+
)
171+
172+
173+
@mcp.tool(app=True)
174+
def render_chart(
175+
rows: list[dict[str, Any]],
176+
x_key: str,
177+
y_key: str,
178+
chart_type: Literal["bar", "line"] = "line",
179+
title: str = "Query Chart",
180+
top_n: int | None = None,
181+
sort_desc: bool = True,
182+
) -> ToolResult:
183+
"""Render rows as a line/bar chart in an MCP App.
184+
185+
Args:
186+
rows: Array of row objects from a query
187+
x_key: Column to use as labels (x-axis)
188+
y_key: Column to use as metric values (y-axis)
189+
chart_type: "line" or "bar"
190+
title: Chart title
191+
top_n: Show only top N rows (default: show all)
192+
sort_desc: Sort by metric descending (default: True)
193+
"""
194+
normalized, truncated = _normalize_rows(rows)
195+
available_keys = sorted({key for row in normalized for key in row})
196+
197+
if x_key not in available_keys:
198+
raise ValueError(f"x_key '{x_key}' not found. Available keys: {available_keys}")
199+
if y_key not in available_keys:
200+
raise ValueError(f"y_key '{y_key}' not found. Available keys: {available_keys}")
201+
202+
display_count = min(top_n, len(normalized)) if top_n else len(normalized)
203+
summary = f"{title}: {display_count} of {len(normalized)} rows, {chart_type} chart by {y_key}"
204+
if truncated:
205+
summary += f" (truncated from {len(rows)})"
206+
207+
return ToolResult(
208+
content=summary,
209+
structured_content=_build_chart_app(
210+
title=title,
211+
rows=normalized,
212+
x_key=x_key,
213+
y_key=y_key,
214+
chart_type=chart_type,
215+
top_n=top_n,
216+
sort_desc=sort_desc,
217+
),
218+
)
219+
220+
221+
if __name__ == "__main__":
222+
if "--stdio" in sys.argv:
223+
mcp.run(transport="stdio")
224+
else:
225+
mcp.run(transport="stdio")

0 commit comments

Comments
 (0)