Tutorials
How to add an interactive UI to your MCP server
Your MCP server already has tools. MCP Apps lets one of them answer with a table, a chart or a board that the user can click, in ChatGPT, Claude, VS Code and other hosts. This guide shows the three ways to add that UI and walks through the fastest one, line by line.
8 min read
Which invoices are still unpaid this month?
Used show_table
#The short answer
To add a UI to an MCP server, you register two things with the MCP Apps extension: a resource with a ui:// URI that returns an HTML document, and a tool whose _meta.ui.resourceUri points at that resource. When a host that supports MCP Apps calls the tool, it loads the HTML in a sandboxed iframe and passes it the tool's data. Hosts that don't render UI keep using the tool's text result, so nothing breaks for them (MCP Apps spec).
The table above is that kind of UI. In a conversation, the model calls a show_* tool with the columns and rows as arguments, and the host draws this view with them.
What changes between approaches is who writes the HTML and the code that talks to the host. There are three ways to do it.
#Three ways to add a UI to an MCP server
| Approach | You write | Good for |
|---|---|---|
By hand with @modelcontextprotocol/ext-apps | The view (HTML, JS, CSS), the bundling and the registration | Full control, custom interactions, no extra dependency |
| A framework (mcp-use, Skybridge, sunpeak, FastMCP...) | Components in the framework's model | A whole app built around a UI, with local emulators |
| Design the widget in Widgetry and link it | A few lines of registration code | Showing data (tables, charts, boards, KPIs) without building a front end |
By hand. The official SDK, @modelcontextprotocol/ext-apps, gives you registerAppTool and registerAppResource for the server and an App class for the view. You write the view, bundle it into one HTML file and serve it as the resource. It is the most flexible path and the most work. The tutorial Build an MCP App in TypeScript covers it from scratch.
With a framework. Several projects wrap the SDK in a larger developer experience. mcp-use registers React components as widgets and ships an inspector. Skybridge is a full-stack TypeScript and React framework with an emulator. sunpeak includes local ChatGPT and Claude simulators for testing. FastMCP has an apps preview for Python servers. Pick one if the UI is the centre of your product and you want its conventions.
Design it and link it. If what you need is to show data the model already has (a table of deals, a revenue chart, a project board), you can design the widget in Widgetry from a template, publish it and register it on your server with a function that reads it from its live URL. Your server keeps its tools and its data; the widget is the only part that comes from outside. The rest of this guide walks through that path, then the variant without the live link.
#Step 1: pick a template
Sign in at Widgetry and start a widget from one of the templates: table, bar chart, metrics, kanban, donut chart and more. Each template has sample data, so you see a working widget from the start. Choose a design (there are twenty, and you can make your own); every widget type works with every design.
For this guide, assume a widget called Sales board, made from the Table template, with the slug sales-board, in an organization with the slug acme.
#Step 2: set the data schema and the sample data
Two parts of the widget decide what your server's tool will look like:
- The data schema (the Schema tab) is a JSON Schema with a root
type: "object". It becomes the input schema of the tool, so it is what the model has to send. For the Table template it asks for atitle, a list ofcolumns(each with akey, alabeland an optionalalign) and a list ofrows. - The sample data (the Data tab) is what the editor previews with. A published widget's sample data must satisfy its schema.
Then, in Settings, write the Description for the agent. The model reads it to decide when to call the tool, so say what the widget shows and what data it is for. A widget cannot be published without one.
#Step 3: preview it in a real host
The editor preview is not a mock-up. It is an MCP Apps host: it loads the same widget document your server will serve and speaks the same protocol as the chat hosts, in light and in dark mode. If a widget loads images from another site, declare their origins under Image domains: hosts block any origin a widget does not declare in its CSP, and the preview blocks the same ones (see MCP Apps CSP explained).
#Step 4: publish the widget
Click Publish. Publishing checks that the schema is valid, that the sample data matches it and that there is a description. Only a published widget can be linked, and from then on Widgetry refuses an edit that would break its schema or its data, so no change breaks the tool on your server.
#Step 5: create a read-only API key
Go to Settings, API keys and create a key with the Read permission. It starts with wgt_ and is shown once. Set it on your server as the environment variable WIDGETRY_API_KEY. A read key is all the live link needs; keep write keys for agents that author widgets (see create MCP App widgets with an AI agent).
#Step 6: register the widget on your server
Install the MCP TypeScript SDK v2 packages and the MCP Apps SDK:
npm install @modelcontextprotocol/server@^2 @modelcontextprotocol/client@^2 @modelcontextprotocol/ext-apps zodThen add the code the editor shows under Take it to your MCP server. For the Sales board, it is this file:
import { RESOURCE_MIME_TYPE, registerAppResource, registerAppTool } from '@modelcontextprotocol/ext-apps/server'
import { fromJsonSchema, type McpServer } from '@modelcontextprotocol/server'
const WIDGET_URL = 'https://widgets.gonzaloverdugo.com/api/v1/organizations/acme/widgets/sales-board/live'
type LinkedWidget = { manifest: { tool: any; resource: any }; document: string }
let cached: { at: number; widget: Promise<LinkedWidget> } | undefined
/** The published widget, read from Widgetry with a read-only key (WIDGETRY_API_KEY) and kept for a minute. */
function linkedWidget(): Promise<LinkedWidget> {
if (!cached || Date.now() - cached.at > 60_000) {
const widget = fetch(WIDGET_URL, { headers: { authorization: `Bearer ${process.env.WIDGETRY_API_KEY}` } }).then(async (response) => {
if (!response.ok) throw new Error(`Widgetry answered ${response.status} for ${WIDGET_URL}`)
return (await response.json()).data as LinkedWidget
})
cached = { at: Date.now(), widget }
widget.catch(() => (cached = undefined))
}
return cached.widget
}
/** Adds the widget to your server: the tool that shows it and its ui:// resource, both read from Widgetry. */
export async function registerSalesBoard(server: McpServer) {
const { tool, resource } = (await linkedWidget()).manifest
registerAppResource(server, tool.title, resource.uri, { description: tool.description, _meta: resource._meta }, async () => {
const { document, manifest } = await linkedWidget()
return { contents: [{ uri: resource.uri, mimeType: RESOURCE_MIME_TYPE, text: document, _meta: manifest.resource._meta }] }
})
registerAppTool(
server,
tool.name,
{
title: tool.title,
description: tool.description,
inputSchema: fromJsonSchema(tool.inputSchema),
annotations: tool.annotations,
_meta: tool._meta,
},
async (data) => ({
content: [{ type: 'text', text: `Showing ${tool.title}` }],
structuredContent: data as Record<string, unknown>,
}),
)
}Call it where you create your server, next to your own tools:
import { McpServer } from '@modelcontextprotocol/server'
import { registerSalesBoard } from './sales-board.js'
const server = new McpServer({ name: 'acme-crm', version: '1.0.0' })
await registerSalesBoard(server)
// Your other tools, then connect the transport as you already do.#What the code does
linkedWidget()fetchesWIDGET_URLwith the key as a Bearer token. The response holds the widget's manifest (the tool definition and the resource registration, CSP included) and the widget document (the self-contained HTML). The result is cached for 60 seconds; a failed request clears the cache so the next call retries.registerAppResourceregisters theui://resource. Its read callback callslinkedWidget()again, so every time a host reads the resource it gets the document as published, at most a minute old.registerAppToolregisters the tool with the name, title, description and input schema from the manifest.fromJsonSchematurns the widget's JSON Schema into the schema type the SDK expects. The handler does no work of its own: it returns the arguments the model sent asstructuredContent, which is what the widget renders, plus a short text.
#What happens when you change the widget
Edit the published widget in Widgetry and save: the live URL serves the new version, and your server picks it up within a minute, with no deploy. The tool definition is read once, when registerSalesBoard runs. A server that builds a new McpServer per request (stateless) gets a schema change on the next request; a long-lived server gets it on its next restart. The document itself always follows the cache.
#The alternative: download the widget and own it
If you would rather not depend on Widgetry at runtime, switch the export panel to Download the files. You get sales-board.html (the widget document) and sales-board.widget.json (the manifest). Keep them next to your server code and register them with this snippet, which reads the two files from disk:
import { readFileSync } from 'node:fs'
import { RESOURCE_MIME_TYPE, registerAppResource, registerAppTool } from '@modelcontextprotocol/ext-apps/server'
import { fromJsonSchema, type McpServer } from '@modelcontextprotocol/server'
const { tool, resource } = JSON.parse(readFileSync(new URL('./sales-board.widget.json', import.meta.url), 'utf8'))
const html = readFileSync(new URL('./sales-board.html', import.meta.url), 'utf8')
/** Adds the widget to your server: one ui:// resource and the tool that shows it. */
export function registerSalesBoard(server: McpServer) {
registerAppResource(server, tool.title, resource.uri, { description: tool.description, _meta: resource._meta }, async () => ({
contents: [{ uri: resource.uri, mimeType: RESOURCE_MIME_TYPE, text: html, _meta: resource._meta }],
}))
registerAppTool(
server,
tool.name,
{
title: tool.title,
description: tool.description,
inputSchema: fromJsonSchema(tool.inputSchema),
annotations: tool.annotations,
_meta: tool._meta,
},
async (data) => ({
content: [{ type: 'text', text: `Showing ${tool.title}` }],
structuredContent: data as Record<string, unknown>,
}),
)
}The registration is the same; only the source changes. The trade-off is the obvious one: the files never change unless you export them again, and exporting does not require publishing.
#What the model sees
Whichever path you take, the host lists one new tool. For the Sales board it looks like this in tools/list (schema shortened):
{
"name": "show_sales_board",
"title": "Sales board",
"description": "Shows open deals as a table: the caller defines the columns and passes one object per row.",
"inputSchema": {
"type": "object",
"required": ["title", "columns", "rows"],
"properties": { "title": { "type": "string" }, "columns": { "type": "array" }, "rows": { "type": "array" } }
},
"annotations": { "readOnlyHint": true, "openWorldHint": false },
"_meta": { "ui": { "resourceUri": "ui://widgets/sales-board.html" } }
}- The name is
show_plus the slug, with hyphens turned into underscores. - The description is the one you wrote for the agent. It is the main signal the model uses to choose the tool, so it deserves a careful sentence.
- The input schema is the widget's data schema. The model fills it with data it got from your other tools or from the user, then calls the widget's tool. The widget never fetches data on its own.
- The annotations say the tool only reads and touches nothing outside.
_meta.ui.resourceUrilinks the tool to the widget document.registerAppToolalso writes the older flat keyui/resourceUrifor hosts that still read it.
#What hosts without UI do
MCP Apps degrades on purpose: a host that does not support the extension treats the tool as an ordinary tool (spec). Both snippets return a content text (Showing Sales board) next to structuredContent, so a text-only host still gets an answer and the data. Claude Code in the terminal is an example: it calls the tool as text and doesn't render the UI (Claude quickstart). The MCP Apps clients guide lists which hosts render widgets.
#How to check it in Claude or ChatGPT
Claude and ChatGPT connect to your server from their own infrastructure, so localhost is not reachable: give the server a public HTTPS URL or use a tunnel while you iterate.
- Claude: add the server by URL as a custom connector (how to), turn it on in a chat and ask for something the widget shows. If the tool runs but no UI appears, Anthropic's MCP Apps troubleshooting page lists what to check.
- ChatGPT: turn on Developer mode under Settings, Security and login, then add the server's URL (including the
/mcppath) from the plugins page. After changing a tool, select Refresh on the connection and start a new conversation (OpenAI docs).
A prompt as plain as "Show me the open deals over 5,000 euros as a table" is enough: the model gathers the rows with your tools and calls show_sales_board with them.
No server yet? Publish the widget and connect Widgetry's own MCP endpoint, https://widgets.gonzaloverdugo.com/mcp, to your agent: chat apps sign in with OAuth, and coding agents use an API key. It serves one show_* tool per published widget of your organization, which is a quick way to see a widget in a real conversation before you write any server code.