When the existing visualization servers don’t cover your needs — custom chart types, complex drill-down interactions, proprietary data structures — you build your own. This note covers the implementation: TypeScript server side, client SDK, and the build tooling that produces a deployable bundle.
The general MCP server setup is covered in MCP Server Project Setup. This note focuses specifically on the MCP Apps extension — the ui:// resource mechanism and the client SDK that makes the UI interactive.
Prerequisites
Node.js 18+ and two packages beyond the standard MCP SDK:
npm install @modelcontextprotocol/sdk @modelcontextprotocol/ext-appsThe @modelcontextprotocol/ext-apps package provides registerAppTool, registerAppResource, and RESOURCE_MIME_TYPE on the server side, plus the App class for the client-side bundle.
Server Side: Registering the App Tool
The server registers two things: a tool with UI metadata, and a resource that serves the HTML bundle.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";import * as fs from "fs/promises";
const server = new McpServer({ name: "Analytics Dashboard", version: "1.0.0" });const resourceUri = "ui://dashboard/main.html";
// Register the tool with UI metadataregisterAppTool(server, "show-dashboard", { title: "Analytics Dashboard", description: "Display interactive metrics dashboard. Use when the user asks for a visualization, chart, or dashboard of their data.", inputSchema: { type: "object", properties: { metrics: { type: "array", description: "Array of metric objects to display", items: { type: "object" } }, chartType: { type: "string", enum: ["bar", "line", "scatter", "pie"], description: "Visualization type" } }, required: ["metrics"] }, _meta: { ui: { resourceUri } }}, async (args) => ({ content: [{ type: "text", text: JSON.stringify(args.metrics) }]}));
// Register the HTML resourceregisterAppResource(server, resourceUri, resourceUri, { mimeType: RESOURCE_MIME_TYPE }, async () => { const html = await fs.readFile("./dist/index.html", "utf-8"); return { contents: [{ uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html }] }; });A few things worth noting in this pattern:
The tool handler returns data as a text content block. The UI layer handles rendering — the tool function doesn’t generate HTML. This separation keeps the server logic clean and testable.
The _meta.ui.resourceUri in the tool schema is what triggers App behavior in the host. Without it, the tool is just a regular MCP tool. With it, the host knows to preload and render the ui:// resource.
The resource handler reads the HTML file at runtime. This means you can update the UI bundle without changing the server code.
Client Side: The App SDK
Inside the HTML bundle, the App class from @modelcontextprotocol/ext-apps manages communication with the host. The bundle needs to import this and set up the event handlers before calling app.connect().
import { App } from "@modelcontextprotocol/ext-apps";
const app = new App({ name: "Dashboard App", version: "1.0.0" });
// Receives the initial data payload when the tool executesapp.ontoolresult = (result) => { const rawText = result.content?.find(c => c.type === "text")?.text; if (!rawText) return; const data = JSON.parse(rawText); renderChart(data);};
// Called from UI interactions — filter changes, drill-downs, segment selectionsasync function loadSegmentDetails(segment: string) { const result = await app.callServerTool({ name: "get-segment-details", arguments: { segment } }); const details = JSON.parse(result.content?.[0]?.text ?? "{}"); updateVisualization(details);}
// Tell the AI what the user just did — enables context-aware follow-up questionsfunction onUserFilter(dimension: string, value: string) { app.updateModelContext({ action: "filtered", dimension, value, timestamp: new Date().toISOString() });}
app.connect();The app.callServerTool() call is what enables genuine interactivity — clicking a bar in your chart calls back to the server to fetch detail data, which re-renders the visualization. This is the interaction model that makes MCP Apps different from a static chart export.
app.updateModelContext() is optional but worth implementing if users will ask follow-up questions after interacting with the visualization. Without it, the AI doesn’t know what the user did in the UI; with it, “why did mobile drop?” arrives with the filter state already attached.
Build Tooling
The host requires a single self-contained HTML file. No external CDN links, no separate stylesheet or script files — everything inlined. Vite with vite-plugin-singlefile handles this:
import { defineConfig } from "vite";import { viteSingleFile } from "vite-plugin-singlefile";
export default defineConfig({ plugins: [viteSingleFile()], build: { outDir: "dist", // Ensure the output is suitable for inline use assetsInlineLimit: Infinity, },});npm add -D vite vite-plugin-singlefilenpm run build# dist/index.html is your deployable bundleThe MCP Apps SDK ships starter templates for React, Vue, Svelte, Preact, Solid, and vanilla JS. For data visualization specifically, React pairs well with libraries like Recharts or Nivo; vanilla JS with Chart.js or D3 is lighter weight and avoids framework overhead in the bundle.
Adding a Drill-Down Tool
Custom visualization servers often need more than one tool — the main display tool and one or more tools for fetching detail data. Register additional tools without _meta (they’re regular MCP tools, not App tools):
server.tool("get-segment-details", { description: "Fetch detailed breakdown data for a specific segment. Called when user drills down into a chart segment.", inputSchema: { type: "object", properties: { segment: { type: "string", description: "Segment identifier to detail" }, dateRange: { type: "object", properties: { start: { type: "string" }, end: { type: "string" } } } }, required: ["segment"] }}, async (args) => { // Query your data source const data = await fetchSegmentDetails(args.segment, args.dateRange); return { content: [{ type: "text", text: JSON.stringify(data) }] };});These secondary tools don’t need UI metadata because they’re called from within the App SDK (app.callServerTool()), not directly by the AI.
Project Structure
my-viz-server/├── package.json├── tsconfig.json├── server.ts # MCP server — tool registration, resource handler├── vite.config.ts # Single-file build config├── src/│ ├── main.ts # App SDK setup, event handlers│ ├── chart.ts # Visualization logic│ └── App.tsx # React component (if using React)└── dist/ └── index.html # Built bundle — served as the ui:// resourceThe server and the client are in the same repository because they need to stay in sync — the server’s tool output format must match what the client expects to receive via ontoolresult. A version mismatch between what the tool returns and what the client can parse is a common source of silent failures.
Testing
Test in layers:
- Unit test the tool handlers directly — call the async functions with test data, verify the JSON output is what you expect.
- Test the bundle in isolation — open
dist/index.htmlin a browser and inject mockontoolresultevents. Verify rendering without needing a running MCP server. - Integration test with MCP Inspector —
npx @modelcontextprotocol/inspector node server.js. Verify the tool appears, the resource loads, and basic tool invocation works. - Test in the actual client — connect to Claude Desktop or Claude Code. Verify the UI renders inside the conversation and interactivity works.
The MCP Server Testing and Debugging note covers the full three-stage testing workflow. For Apps specifically, don’t skip the in-client test — the iframe sandbox and the postMessage communication channel introduce failure modes that the Inspector doesn’t surface.