On this page9 sections
Customization: register your
AppDefinitionviaapps:onAppRegistry. Your app receivescontext.dataLayer(the projection-layer seam). For the wiring/sandbox internals, see Apps architecture.
Writing a Custom MCP App
mcp-rune ships seven MCP App tools — interactive iframe widgets the LLM can summon: new_model_app, edit_model_app, find_model_app, show_model_app, pick_model_app, multi_pick_model_app, view_selection_app. They share infrastructure: the kind taxonomy from src/mcp/models/kinds/, the renderer registry from apps/shared/kind-renderers.ts, the form-schema generator, the selection store, theming.
Projection-layer rule. App handlers consume only the
DataLayerinterface —context.dataLayeris the single data-access seam. Handlers never receivesearchClient,apiClient, or any concrete adapter. See The Projection-Layer Rule.
You write a custom app when the seven don’t fit. Examples:
- A read-only dashboard that aggregates several models into a single view.
- A bulk-edit grid with cell-level validation.
- A specialized picker for a domain object (e.g. a calendar for a
bookingmodel). - A printable artifact (invoice, certificate) the user can trigger from the chat.
This guide covers the AppDefinition shape, the three categories of app, the deployer-facing single-file path, and the advanced Vite-bundled path that reuses the kind taxonomy.
For the protocol-level deep dive — how iframe communication works, message ordering, the @modelcontextprotocol/ext-apps SDK — see MCP Apps Architecture. For the user-facing introduction, see MCP Apps Guide.
Table of Contents
AppDefinitionShape- Three Categories of App
- Anatomy of a Framework App
- Single-File Custom App
- Vite-Bundled Custom App
- Reusing the Kind Taxonomy
- Wiring Into the Registry
- Testing a Custom App
AppDefinition Shape
import type { AppDefinition } from '@mcp-rune/mcp-rune/apps'
interface AppDefinition {
name: string // unique identifier
description: string // shown to the LLM
resourceUri?: string // ui://<namespace>/<app> where the iframe HTML lives
toolName?: string // MCP tool that opens the app
toolDescription?: string
toolInputSchema?: Record<string, unknown> // Zod-flavored input schema
needsAuth?: boolean // requires an authenticated request
visibility?: string[] // optional visibility filter
annotations?: {
// MCP tool annotations
readOnlyHint?: boolean
destructiveHint?: boolean
idempotentHint?: boolean
openWorldHint?: boolean
}
handleToolCall?(args, context): Promise<ToolResult> // server-side handler
getHtml?(): string // HTML for the iframe
}/**
* Full shape of an MCP App. Every field is optional except `name` and
* `description`; what you populate determines the app's category
* (pure UI / tool-backed / resource + tool).
*
* @typedef {Object} AppAnnotations
* @property {boolean} [readOnlyHint]
* @property {boolean} [destructiveHint]
* @property {boolean} [idempotentHint]
* @property {boolean} [openWorldHint]
*
* @typedef {Object} AppDefinition
* @property {string} name unique identifier
* @property {string} description shown to the LLM
* @property {string} [resourceUri] `ui://<namespace>/<app>` where the iframe HTML lives
* @property {string} [toolName] MCP tool that opens the app
* @property {string} [toolDescription]
* @property {Object} [toolInputSchema] Zod-flavored input schema
* @property {boolean} [needsAuth] requires an authenticated request
* @property {string[]} [visibility] optional visibility filter
* @property {AppAnnotations} [annotations]
* @property {(args: Object, context: Object) => Promise<ToolResult>} [handleToolCall]
* @property {() => string} [getHtml] HTML for the iframe
*/Every field is optional except name and description. What you populate determines which category your app falls into.
The three categories form a simple decision tree:
Almost every framework-shipped app is resource + tool: the LLM calls the tool, the handler fetches data, and the response points the iframe at a ui:// resource the host then loads.
Three Categories of App
| Category | Populates | Use when |
|---|---|---|
| Pure UI | getHtml, resourceUri | The app is static or self-contained — no server-side work. |
| Tool-backed | toolName, toolInputSchema, handleToolCall | A tool the LLM calls (with no iframe), e.g. get_field_suggestions. |
| Resource + tool | All of the above | The standard interactive widget pattern — LLM calls the tool with arguments, server returns metadata pointing the iframe to the resource. |
The shipped apps are almost all category three. show-model-app, for instance: the LLM calls show_model_app(model, ids), the handler fetches records and returns a ToolResult with _meta referencing the ui://bookshelf/show-model-app resource; the host loads the HTML, the iframe receives the data via postMessage and renders.
Anatomy of a Framework App
Take show-model-app:
The server file (show-model-app/index.ts):
// A faithful sketch of the shipped show-model-app's handler. The data the
// iframe renders travels as JSON in `content[0].text`; `_meta` carries only
// a short summary string (`appResponseMeta` takes a string, not an object).
export function createShowModelApp(opts: { models: ModelsRegistry }): AppDefinition {
return {
name: 'Show Record',
description: 'Read-only detail cards for viewing one or more records',
resourceUri: 'ui://bookshelf/show-model-app',
toolName: 'show_model_app',
toolDescription: 'Open the show-model-app viewer with the given IDs.',
toolInputSchema: { model: z.enum(modelNames), ids: z.array(z.string()).optional() },
annotations: { readOnlyHint: true },
getHtml: () => loadHtml(),
needsAuth: true,
async handleToolCall(args, context) {
const records = await fetchRecords(context.dataLayer, args.model, args.ids)
const schema = generateDetailSchema(opts.models[args.model])
return {
// The iframe consumes this JSON payload via the host message.
content: [{ type: 'text', text: JSON.stringify({ schema, records }) }],
// The LLM sees only this summary; it must not repeat record contents.
_meta: appResponseMeta(formatAppSummary(records))
}
}
}
}export function createShowModelApp(opts) {
return {
name: 'Show Record',
description: 'Read-only detail cards for viewing one or more records',
resourceUri: 'ui://bookshelf/show-model-app',
toolName: 'show_model_app',
toolDescription: 'Open the show-model-app viewer with the given IDs.',
toolInputSchema: { model: z.enum(modelNames), ids: z.array(z.string()).optional() },
annotations: { readOnlyHint: true },
getHtml: () => loadHtml(),
needsAuth: true,
async handleToolCall(args, context) {
const records = await fetchRecords(context.dataLayer, args.model, args.ids)
const schema = generateDetailSchema(opts.models[args.model])
return {
content: [{ type: 'text', text: JSON.stringify({ schema, records }) }],
_meta: appResponseMeta(formatAppSummary(records))
}
}
}
}The handler fetches data, ships { schema, records } as JSON in content[0].text (the channel the iframe reads), and puts only a short human summary in _meta via appResponseMeta(summary) — the summary is what the LLM sees, so it doesn’t re-narrate the records the cards already display.
The iframe (show-model-app/ui/app.js) parses the records out of the host message, walks the schema, calls renderCellValue(value, field) from apps/shared/kind-renderers.ts for each field, and appends nodes to the DOM. Kind-aware rendering, theming, and selection state are inherited automatically.
The custom booking-calendar example below uses its own
_meta['mcp-rune/payload']key to pass data to a hand-authored iframe. That’s a valid deployer choice —_metais a free-form envelope reaching the iframe viaontoolresult— but note it differs from how the framework’s own apps ship data (JSON incontent, summary in_meta). Pick one channel and use it consistently.
Single-File Custom App
For most deployer-written apps, you don’t need Vite. Ship a single inline HTML string:
// your-server/apps/booking-calendar.ts
import { z } from 'zod'
import type { AppDefinition } from '@mcp-rune/mcp-rune/apps'
export function createBookingCalendarApp(): AppDefinition {
return {
name: 'booking_calendar',
description: 'Display a monthly calendar of bookings.',
resourceUri: 'ui://bookshelf/booking-calendar',
toolName: 'show_booking_calendar',
toolDescription: 'Open the calendar for a given month.',
toolInputSchema: { month: z.string().regex(/^\d{4}-\d{2}$/) },
annotations: { readOnlyHint: true },
needsAuth: true,
getHtml: () => HTML,
async handleToolCall(args, context) {
const month = (args as { month: string }).month
const records = await context.dataLayer.list('booking', { month })
return {
content: [{ type: 'text', text: `Loaded ${records.length} bookings for ${month}` }],
_meta: { 'mcp-rune/payload': { records, month } }
}
}
}
}
const HTML = `<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>Bookings</title>
<style>body { font-family: system-ui; padding: 1rem; }</style></head>
<body>
<h1>Bookings</h1>
<div id="grid"></div>
<script type="module">
import { App } from 'https://esm.sh/@modelcontextprotocol/ext-apps@1.7.1'
const app = new App({ name: 'Bookings', version: '1.0.0' })
app.ontoolresult = (result) => {
const payload = result._meta?.['mcp-rune/payload']
document.getElementById('grid').textContent =
'Month: ' + payload.month + ' — ' + payload.records.length + ' bookings'
}
await app.connect()
</script>
</body></html>`// your-server/apps/booking-calendar.ts
import { z } from 'zod'
export function createBookingCalendarApp() {
return {
name: 'booking_calendar',
description: 'Display a monthly calendar of bookings.',
resourceUri: 'ui://bookshelf/booking-calendar',
toolName: 'show_booking_calendar',
toolDescription: 'Open the calendar for a given month.',
toolInputSchema: { month: z.string().regex(/^\d{4}-\d{2}$/) },
annotations: { readOnlyHint: true },
needsAuth: true,
getHtml: () => HTML,
async handleToolCall(args, context) {
const month = args.month
const records = await context.dataLayer.list('booking', { month })
return {
content: [{ type: 'text', text: `Loaded ${records.length} bookings for ${month}` }],
_meta: { 'mcp-rune/payload': { records, month } }
}
}
}
}
const HTML = `<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>Bookings</title>
<style>body { font-family: system-ui; padding: 1rem; }</style></head>
<body>
<h1>Bookings</h1>
<div id="grid"></div>
<script type="module">
import { App } from 'https://esm.sh/@modelcontextprotocol/ext-apps@1.7.1'
const app = new App({ name: 'Bookings', version: '1.0.0' })
app.ontoolresult = (result) => {
const payload = result._meta?.['mcp-rune/payload']
document.getElementById('grid').textContent =
'Month: ' + payload.month + ' — ' + payload.records.length + ' bookings'
}
await app.connect()
</script>
</body></html>`No build step. The string is the iframe HTML; mcp-rune injects theming + formatters via injectIntoHead before serving it. Good enough for read-only views, dashboards, and printable artifacts.
The shape stays the same whether you author the HTML by hand or generate it. The _meta envelope is your communication channel from server to iframe — anything you put in 'mcp-rune/payload' (or your own key) reaches the iframe via the ontoolresult handler on the App instance (result._meta).
Vite-Bundled Custom App
For end-to-end references, mcp-rune’s own src/mcp/apps/*/ui/app.js files and the upstream basic-server-vanillajs example both follow the pattern below.
If your app is interactive enough to want JS modules, CSS imports, and bundled dependencies, follow the framework pattern:
- Create
your-server/apps/booking-calendar-ui/:index.html— page skeleton.app.js— UI logic, imports from@mcp-rune/mcp-rune/apps.styles.css— your styles.
- Add a
vite.config.jsmirroringsrc/mcp/apps/vite.config.js. UseviteSingleFile()so the output is one self-contained.html. - Build into
your-server/apps/dist/booking-calendar.html. - In your
AppDefinition, read the file at server startup:
import fs from 'node:fs'
import path from 'node:path'
const HTML_PATH = path.resolve(import.meta.dirname, 'dist/booking-calendar.html')
let _cached: string | null = null
function loadHtml() {
if (!_cached) _cached = fs.readFileSync(HTML_PATH, 'utf-8')
return _cached
}import fs from 'node:fs'
import path from 'node:path'
const HTML_PATH = path.resolve(import.meta.dirname, 'dist/booking-calendar.html')
let _cached = null
function loadHtml() {
if (!_cached) _cached = fs.readFileSync(HTML_PATH, 'utf-8')
return _cached
}Use this when your app:
- Imports the framework’s
helpers,renderCellValue, orgetKindRenderer(so it inherits theming, kind rendering, and overrides). - Needs more than ~200 lines of UI code.
- Wants to share components across multiple custom apps in your deployment.
Reusing the Kind Taxonomy
The whole point of having a shared kind registry is so custom apps don’t reinvent rendering. In a Vite-bundled custom app, import the same primitives the framework apps use:
// your-server/apps/booking-calendar-ui/app.js
import { App } from '@modelcontextprotocol/ext-apps'
import { renderCellValue, getKindRenderer, helpers } from '@mcp-rune/mcp-rune/apps/kind-renderers'
const app = new App({ name: 'Booking Calendar', version: '1.0.0' })
const tbody = document.querySelector('tbody')!
app.ontoolresult = (result) => {
const payload = result._meta?.['mcp-rune/payload']
for (const booking of payload.records) {
const tr = document.createElement('tr')
for (const col of payload.schema.columns) {
const td = document.createElement('td')
td.appendChild(renderCellValue(booking[col.name], col))
tr.appendChild(td)
}
tbody.appendChild(tr)
}
}
await app.connect()// your-server/apps/booking-calendar-ui/app.js
import { App } from '@modelcontextprotocol/ext-apps'
import { renderCellValue, getKindRenderer, helpers } from '@mcp-rune/mcp-rune/apps/kind-renderers'
const app = new App({ name: 'Booking Calendar', version: '1.0.0' })
const tbody = document.querySelector('tbody')
app.ontoolresult = (result) => {
const payload = result._meta?.['mcp-rune/payload']
for (const booking of payload.records) {
const tr = document.createElement('tr')
for (const col of payload.schema.columns) {
const td = document.createElement('td')
td.appendChild(renderCellValue(booking[col.name], col))
tr.appendChild(td)
}
tbody.appendChild(tr)
}
}
await app.connect()That single renderCellValue call picks up:
- Built-in kinds with their
format()renderers. - Any deployer-registered overrides via
AppRegistry({ kinds })(theming, locale, badges). - The
--color-accentetc. CSS variables fromthemeOverrides.
No re-implementation. If you later add a new kind to your server, your custom app renders it without code changes.
For form-style inputs, use getKindRenderer(kind, format).toInput / .fromInput / .parse / .serialize from the same module — these come from the shared kinds registry and round-trip correctly for every kind.
Wiring Into the Registry
If you use createDefaultAppRegistry, pass extra apps in the apps array:
import { createDefaultAppRegistry } from '@mcp-rune/mcp-rune/apps'
import { createBookingCalendarApp } from './apps/booking-calendar.js'
export const appRegistry = createDefaultAppRegistry({
modelClasses: MODEL_CLASSES,
namespace: 'bookings',
apps: [createBookingCalendarApp()] // alongside the seven framework apps
})import { createDefaultAppRegistry } from '@mcp-rune/mcp-rune/apps'
import { createBookingCalendarApp } from './apps/booking-calendar.js'
export const appRegistry = createDefaultAppRegistry({
modelClasses: MODEL_CLASSES,
namespace: 'bookings',
apps: [createBookingCalendarApp()] // alongside the seven framework apps
})If you’re hand-wiring AppRegistry:
import { AppRegistry } from '@mcp-rune/mcp-rune/apps'
const registry = new AppRegistry(
[
createBookingCalendarApp()
// …
],
{
/* registry options */
}
)
// Or register after construction:
registry.registerApp(createBookingCalendarApp())import { AppRegistry } from '@mcp-rune/mcp-rune/apps'
const registry = new AppRegistry(
[
createBookingCalendarApp()
// …
],
{
/* registry options */
}
)
// Or register after construction:
registry.registerApp(createBookingCalendarApp())Apps registered after registerResources is called won’t appear; register them all before the server accepts connections.
Testing a Custom App
Test the server-side handler with vitest, using a stub DataLayer:
import { describe, expect, it } from 'vitest'
import { createBookingCalendarApp } from '../src/apps/booking-calendar'
describe('booking-calendar app', () => {
it('returns records under mcp-rune/payload in _meta', async () => {
const app = createBookingCalendarApp()
const result = await app.handleToolCall!(
{ month: '2026-05' },
{ dataLayer: { list: async () => [{ id: '1', date: '2026-05-12' }] } }
)
expect(result._meta?.['mcp-rune/payload']).toMatchObject({
month: '2026-05',
records: [{ id: '1', date: '2026-05-12' }]
})
})
})import { describe, expect, it } from 'vitest'
import { createBookingCalendarApp } from '../src/apps/booking-calendar'
describe('booking-calendar app', () => {
it('returns records under mcp-rune/payload in _meta', async () => {
const app = createBookingCalendarApp()
const result = await app.handleToolCall(
{ month: '2026-05' },
{ dataLayer: { list: async () => [{ id: '1', date: '2026-05-12' }] } }
)
expect(result._meta?.['mcp-rune/payload']).toMatchObject({
month: '2026-05',
records: [{ id: '1', date: '2026-05-12' }]
})
})
})Test the iframe rendering with happy-dom:
/**
* @vitest-environment happy-dom
*/
import { describe, expect, it } from 'vitest'
import { renderCellValue } from '@mcp-rune/mcp-rune/apps/kind-renderers'
describe('booking-calendar iframe', () => {
it('renders a booking row through the shared formatter', () => {
const td = renderCellValue('2026-05-12', { kind: 'date' })
expect(td.textContent).toContain('2026')
})
})/**
* @vitest-environment happy-dom
*/
import { describe, expect, it } from 'vitest'
import { renderCellValue } from '@mcp-rune/mcp-rune/apps/kind-renderers'
describe('booking-calendar iframe', () => {
it('renders a booking row through the shared formatter', () => {
const td = renderCellValue('2026-05-12', { kind: 'date' })
expect(td.textContent).toContain('2026')
})
})Integration-test the iframe via injectIntoHead to confirm theming and kind render hints reach your HTML:
import { AppRegistry } from '@mcp-rune/mcp-rune/apps'
const registry = new AppRegistry([createBookingCalendarApp()], {
kinds: { date: { render: { locale: 'en-GB' } } }
})
const html = registry.injectIntoHead(loadHtml())
expect(html).toContain('window.__MCP_RUNE_KIND_RENDERERS__')
expect(html).toContain('en-GB')import { AppRegistry } from '@mcp-rune/mcp-rune/apps'
const registry = new AppRegistry([createBookingCalendarApp()], {
kinds: { date: { render: { locale: 'en-GB' } } }
})
const html = registry.injectIntoHead(loadHtml())
expect(html).toContain('window.__MCP_RUNE_KIND_RENDERERS__')
expect(html).toContain('en-GB')Related guides:
- MCP Apps Guide — user-facing overview of the apps system.
- MCP Apps Architecture — protocol-level reference for iframe ↔ host communication.
- Attribute Kinds — the kind taxonomy custom apps inherit.
- Model Form Customization — layout primitives for the generic
model-form(often enough; you may not need a custom app).