-
Notifications
You must be signed in to change notification settings - Fork 44
Add MCP App visualization server for analyzing-data skill #155
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
kaxil
wants to merge
2
commits into
main
Choose a base branch
from
kaxil/mcp-app-viz
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,221 @@ | ||
| #!/usr/bin/env python3 | ||
| # /// script | ||
| # requires-python = ">=3.11" | ||
| # dependencies = [ | ||
| # "fastmcp[apps]>=3.1.0", | ||
| # "prefab-ui==0.10.0", | ||
| # ] | ||
| # /// | ||
| """Visualization-only MCP server for the analyzing-data skill. | ||
|
|
||
| This server is intentionally small: | ||
| - Query execution and business logic stay in `scripts/cli.py` | ||
| - This server only renders MCP App visualizations (table/chart) | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from collections.abc import Mapping | ||
| from typing import Any, Literal | ||
|
|
||
| from fastmcp import FastMCP | ||
| from fastmcp.tools import ToolResult | ||
| from prefab_ui.app import PrefabApp | ||
| from prefab_ui.components import ( | ||
| Column, | ||
| DataTable, | ||
| DataTableColumn, | ||
| Heading, | ||
| Muted, | ||
| Tab, | ||
| Tabs, | ||
| ) | ||
| from prefab_ui.components.charts import BarChart, ChartSeries, LineChart | ||
|
|
||
| MAX_ROWS = 500 | ||
|
|
||
| mcp = FastMCP( | ||
| "Analytics Visualization MCP", | ||
| instructions=( | ||
| "Visualization-only server for query result display. " | ||
| "Use render_table for tabular results and render_chart for line/bar charts. " | ||
| "Do not run SQL here." | ||
| ), | ||
| ) | ||
|
|
||
|
|
||
| def _to_json_safe(value: Any) -> Any: | ||
| """Convert values into JSON-safe types for tool payloads.""" | ||
| if isinstance(value, (str, int, float, bool)) or value is None: | ||
| return value | ||
| if isinstance(value, Mapping): | ||
| return {str(key): _to_json_safe(item) for key, item in value.items()} | ||
| if isinstance(value, (list, tuple)): | ||
| return [_to_json_safe(item) for item in value] | ||
| return str(value) | ||
|
|
||
|
|
||
| def _normalize_rows(rows: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], bool]: | ||
| """Normalize rows and enforce a max payload size.""" | ||
| if not isinstance(rows, list): | ||
| raise ValueError("rows must be a list of objects") | ||
|
|
||
| normalized: list[dict[str, Any]] = [] | ||
| for row in rows[:MAX_ROWS]: | ||
| if not isinstance(row, Mapping): | ||
| raise ValueError("each row must be an object/dict") | ||
| normalized.append( | ||
| {str(key): _to_json_safe(value) for key, value in row.items()} | ||
| ) | ||
|
|
||
| return normalized, len(rows) > MAX_ROWS | ||
|
|
||
|
|
||
| def _build_table_app(title: str, rows: list[dict[str, Any]]) -> PrefabApp: | ||
| """Build a Prefab DataTable app.""" | ||
| keys = list(dict.fromkeys(k for row in rows for k in row)) | ||
| columns = [DataTableColumn(key=k, header=k, sortable=True) for k in keys] | ||
|
|
||
| with Column(gap=4, css_class="p-6") as view: | ||
| Heading(title) | ||
| Muted(f"{len(rows)} rows") | ||
| DataTable( | ||
| columns=columns, | ||
| rows=rows, | ||
| searchable=True, | ||
| paginated=True, | ||
| page_size=20, | ||
| ) | ||
|
|
||
| return PrefabApp(view=view) | ||
|
|
||
|
|
||
| def _build_chart_app( | ||
| title: str, | ||
| rows: list[dict[str, Any]], | ||
| x_key: str, | ||
| y_key: str, | ||
| chart_type: str, | ||
| top_n: int | None, | ||
| sort_desc: bool, | ||
| ) -> PrefabApp: | ||
| """Build a Prefab chart app with tabs for chart and table views.""" | ||
| if sort_desc: | ||
| rows = sorted(rows, key=lambda r: _numeric(r.get(y_key)), reverse=True) | ||
| else: | ||
| rows = sorted(rows, key=lambda r: _numeric(r.get(y_key))) | ||
|
|
||
| display_rows = rows[:top_n] if top_n and top_n > 0 else rows | ||
|
|
||
| Chart = BarChart if chart_type == "bar" else LineChart | ||
| keys = list(dict.fromkeys(k for row in rows for k in row)) | ||
| columns = [DataTableColumn(key=k, header=k, sortable=True) for k in keys] | ||
|
|
||
| with Column(gap=4, css_class="p-6") as view: | ||
| Heading(title) | ||
| Muted( | ||
| f"Showing {len(display_rows)} of {len(rows)} rows · Ask in chat for a different count" | ||
| ) | ||
| with Tabs(): | ||
| with Tab("Chart"): | ||
| Chart( | ||
| data=display_rows, | ||
| series=[ChartSeries(data_key=y_key, label=y_key)], | ||
| x_axis=x_key, | ||
| show_legend=True, | ||
| ) | ||
| with Tab("Table"): | ||
| DataTable( | ||
| columns=columns, | ||
| rows=display_rows, | ||
| searchable=True, | ||
| paginated=True, | ||
| page_size=20, | ||
| ) | ||
|
|
||
| return PrefabApp(view=view) | ||
|
|
||
|
|
||
| def _numeric(value: Any) -> float: | ||
| """Extract a numeric value for sorting, defaulting to -inf.""" | ||
| if isinstance(value, (int, float)): | ||
| return float(value) | ||
| if isinstance(value, str): | ||
| try: | ||
| return float(value.replace(",", "").replace("$", "")) | ||
| except ValueError: | ||
| pass | ||
| return float("-inf") | ||
|
|
||
|
|
||
| @mcp.tool(app=True) | ||
| def render_table( | ||
| rows: list[dict[str, Any]], | ||
| title: str = "Query Results", | ||
| ) -> ToolResult: | ||
| """Render rows in an interactive table MCP App. | ||
|
|
||
| Args: | ||
| rows: Array of row objects from a query | ||
| title: Table title | ||
| """ | ||
| normalized, truncated = _normalize_rows(rows) | ||
| summary = f"{title}: {len(normalized)} rows" | ||
| if truncated: | ||
| summary += f" (truncated from {len(rows)})" | ||
| return ToolResult( | ||
| content=summary, | ||
| structured_content=_build_table_app(title, normalized), | ||
| ) | ||
|
|
||
|
|
||
| @mcp.tool(app=True) | ||
| def render_chart( | ||
| rows: list[dict[str, Any]], | ||
| x_key: str, | ||
| y_key: str, | ||
| chart_type: Literal["bar", "line"] = "line", | ||
| title: str = "Query Chart", | ||
| top_n: int | None = None, | ||
| sort_desc: bool = True, | ||
| ) -> ToolResult: | ||
| """Render rows as a line/bar chart in an MCP App. | ||
|
|
||
| Args: | ||
| rows: Array of row objects from a query | ||
| x_key: Column to use as labels (x-axis) | ||
| y_key: Column to use as metric values (y-axis) | ||
| chart_type: "line" or "bar" | ||
| title: Chart title | ||
| top_n: Show only top N rows (default: show all) | ||
| sort_desc: Sort by metric descending (default: True) | ||
| """ | ||
| normalized, truncated = _normalize_rows(rows) | ||
| available_keys = sorted({key for row in normalized for key in row}) | ||
|
|
||
| if x_key not in available_keys: | ||
| raise ValueError(f"x_key '{x_key}' not found. Available keys: {available_keys}") | ||
| if y_key not in available_keys: | ||
| raise ValueError(f"y_key '{y_key}' not found. Available keys: {available_keys}") | ||
|
|
||
| display_count = min(top_n, len(normalized)) if top_n else len(normalized) | ||
| summary = f"{title}: {display_count} of {len(normalized)} rows, {chart_type} chart by {y_key}" | ||
| if truncated: | ||
| summary += f" (truncated from {len(rows)})" | ||
|
|
||
| return ToolResult( | ||
| content=summary, | ||
| structured_content=_build_chart_app( | ||
| title=title, | ||
| rows=normalized, | ||
| x_key=x_key, | ||
| y_key=y_key, | ||
| chart_type=chart_type, | ||
| top_n=top_n, | ||
| sort_desc=sort_desc, | ||
| ), | ||
| ) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| mcp.run(transport="stdio") |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You mention a Claude plugin too in the PR description. Do you have that update locally to push?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Already done — removed the
mcpServersblock from.claude-plugin/plugin.jsonsince MCP Apps are Cursor-only. PR description updated too.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Claude Code doesn't supprot MCP Apps unfortunately. And Claude CoWork is sandboxed by default to allow a lot of what we use