Tutorials

Build an MCP App in TypeScript

A complete MCP Apps tutorial with the official SDK, @modelcontextprotocol/ext-apps: a server tool linked to a ui:// resource, a view that talks to the host with the App class, a one-file build, and which SDK version to pick now that v2 is out.

7 min read

What you will build

In this MCP Apps tutorial you build a small MCP server in TypeScript with one tool, show_checklist, that answers with an interactive release checklist instead of text. The user ticks items off inside the conversation, and a button asks the model for a summary. Along the way you use every piece of the official SDK, @modelcontextprotocol/ext-apps: registerAppTool and registerAppResource on the server, and the App class in the view.

The code targets ext-apps 2.0.3 with the MCP TypeScript SDK v2 packages and zod 4.2, as of October 2026. If your server still runs on the v1 SDK, read ext-apps v1 or v2 first: the differences are small and listed there.

An MCP App has three parts:

  1. A tool the model calls, with _meta.ui.resourceUri pointing at a UI resource.
  2. A resource with a ui:// URI and the MIME type text/html;profile=mcp-app, whose content is one HTML document.
  3. The view: that HTML document, running in a sandboxed iframe in the host, talking to the host over JSON-RPC on postMessage.

If you want the concepts first, read What are MCP Apps?.

ext-apps v1 or v2: which one to pick

@modelcontextprotocol/ext-apps 2.0.0 shipped on 8 September 2026, and the latest release is 2.0.3. The main change is underneath it: 2.x depends on the split MCP TypeScript SDK v2 packages (@modelcontextprotocol/server, @modelcontextprotocol/client, @modelcontextprotocol/core, plus @modelcontextprotocol/node and @modelcontextprotocol/express for HTTP) instead of @modelcontextprotocol/sdk 1.x, and it requires zod@^4.2.0 (migration guide).

What did not change is the wire protocol. The README is explicit: a 2.x view works in a 1.x host, and a 2.x host renders 1.x views. Your choice of version does not decide which hosts render your app.

There is one source of confusion: Claude's own MCP Apps quickstart still installs @modelcontextprotocol/ext-apps@^1 (verified with 1.7.5), because it builds on a server that uses @modelcontextprotocol/sdk 1.x. Both are correct for their starting point.

Which to pick:

  • New server: v2. It is what the official quickstart and README install, it is the line that gets new work, and you avoid a migration later.
  • Existing server on @modelcontextprotocol/sdk 1.x: stay on ext-apps ^1 until you migrate the server SDK. ext-apps 2.x has the v2 packages as peers, so mixing it with SDK 1.x is the one thing that does not work.

The code differences you will meet in this tutorial:

v1 (ext-apps@^1)v2 (ext-apps@^2)
McpServer import@modelcontextprotocol/sdk/server/mcp.js@modelcontextprotocol/server
stdio transport@modelcontextprotocol/sdk/server/stdio.js@modelcontextprotocol/server/stdio
inputSchemaa raw zod shape { release: z.string() }z.object({ release: z.string() }) (raw shapes still work, deprecated)
zod3 or 4^4.2.0 only
registerAppTool, registerAppResource, Appsame names and argumentssame names and arguments

Set up the project

Create a folder and install the packages. For a server, the README lists ext-apps with the client and server packages and zod:

bash
mkdir release-checklist && cd release-checklist
npm init -y
npm pkg set type=module
npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/client@^2.0.0 @modelcontextprotocol/server@^2.0.0 zod@^4.2.0
npm install -D typescript tsx vite vite-plugin-singlefile @types/node

If you will serve over HTTP with Express, also add @modelcontextprotocol/node@^2.0.0 and @modelcontextprotocol/express@^2.0.0.

The server: registerAppTool and registerAppResource

registerAppTool and registerAppResource come from @modelcontextprotocol/ext-apps/server. They wrap the SDK's registerTool and registerResource, fill in the UI metadata (including the older flat _meta["ui/resourceUri"] key for older hosts) and default the resource to RESOURCE_MIME_TYPE, which is text/html;profile=mcp-app.

ts
// server.ts
import { readFile } from 'node:fs/promises'
import { RESOURCE_MIME_TYPE, registerAppResource, registerAppTool } from '@modelcontextprotocol/ext-apps/server'
import { McpServer } from '@modelcontextprotocol/server'
import { z } from 'zod'

const VIEW_URI = 'ui://checklist/view.html'

type Item = { id: string; label: string; done: boolean }

// In memory, for the tutorial. A real server reads this from its own data.
const items: Item[] = [
  { id: 'notes', label: 'Write the release notes', done: true },
  { id: 'qa', label: 'QA sign-off', done: false },
  { id: 'deploy', label: 'Deploy to production', done: false },
]

function checklist(release: string) {
  const open = items.filter((item) => !item.done).length
  return {
    // Text-only hosts and the model read this.
    content: [{ type: 'text' as const, text: `Release ${release}: ${open} of ${items.length} items still open.` }],
    // The view reads this.
    structuredContent: { release, items },
  }
}

export function createServer(): McpServer {
  const server = new McpServer({ name: 'release-checklist', version: '1.0.0' })

  registerAppTool(
    server,
    'show_checklist',
    {
      title: 'Release checklist',
      description: 'Shows the checklist of a release so the user can review it and tick items off.',
      inputSchema: z.object({ release: z.string().describe('Release name, such as v2.4') }),
      _meta: { ui: { resourceUri: VIEW_URI } },
    },
    async ({ release }) => checklist(release),
  )

  // Called by the view, not by the model: visibility ["app"] hides it from the model.
  registerAppTool(
    server,
    'toggle_item',
    {
      description: 'Marks a checklist item as done or not done.',
      inputSchema: z.object({ release: z.string(), id: z.string() }),
      _meta: { ui: { resourceUri: VIEW_URI, visibility: ['app'] } },
    },
    async ({ release, id }) => {
      const item = items.find((entry) => entry.id === id)
      if (item) item.done = !item.done
      return checklist(release)
    },
  )

  registerAppResource(server, 'Release checklist view', VIEW_URI, { description: 'Interactive release checklist' }, async () => ({
    contents: [
      {
        uri: VIEW_URI,
        mimeType: RESOURCE_MIME_TYPE,
        text: await readFile(new URL('./dist/view.html', import.meta.url), 'utf8'),
      },
    ],
  }))

  return server
}

Three details matter here:

  • content and structuredContent together. The view draws from structuredContent. The content text is what a host without MCP Apps support shows, and what the model reads. Claude Code, for example, calls the tool as text and doesn't render the UI (Claude quickstart). Never leave it empty.
  • visibility: ['app'] keeps toggle_item out of the model's tool list while the view can still call it. The default is ["model", "app"].
  • The resource URI in the tool's _meta and in registerAppResource must be the same string.

The entry point connects the server to a transport. For local testing in Claude Desktop, stdio is the shortest:

ts
// main.ts
import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'
import { createServer } from './server.js'

await createServer().connect(new StdioServerTransport())

Claude on the web and ChatGPT connect to remote servers over HTTP. The official quickstart has a complete main.ts that serves the same createServer() over Streamable HTTP with @modelcontextprotocol/express and @modelcontextprotocol/node, one fresh server per request.

The view: the App class

The view is plain HTML plus a module script. Start with the markup:

html
<!-- view.html -->
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="color-scheme" content="light dark" />
    <title>Release checklist</title>
  </head>
  <body>
    <h1 id="title">Waiting for the release...</h1>
    <ul id="items"></ul>
    <button id="summary" type="button">Ask for a summary</button>
    <a id="process" href="https://example.com/release-process">Release process</a>
    <script type="module" src="/src/view.ts"></script>
  </body>
</html>

Then the script. App comes from the package root:

ts
// src/view.ts
import { App, applyDocumentTheme, applyHostStyleVariables } from '@modelcontextprotocol/ext-apps'

type Item = { id: string; label: string; done: boolean }
type Checklist = { release: string; items: Item[] }

const app = new App({ name: 'Release checklist', version: '1.0.0' })
const $ = (id: string) => document.getElementById(id)!
let release = ''

function render(data: Checklist) {
  release = data.release
  $('title').textContent = `Release ${data.release}`
  $('items').replaceChildren(
    ...data.items.map((item) => {
      const box = Object.assign(document.createElement('input'), { type: 'checkbox', checked: item.done })
      box.addEventListener('change', async () => {
        // A server tool, called through the host.
        const result = await app.callServerTool({ name: 'toggle_item', arguments: { release, id: item.id } })
        render(result.structuredContent as Checklist)
      })
      const label = document.createElement('label')
      label.append(box, ` ${item.label}`)
      const row = document.createElement('li')
      row.append(label)
      return row
    }),
  )
}

function applyHostContext() {
  const context = app.getHostContext()
  if (context?.theme) applyDocumentTheme(context.theme)
  if (context?.styles?.variables) applyHostStyleVariables(context.styles.variables)
}

// 1. Handlers first, so the first notifications are not missed.
app.ontoolinput = ({ arguments: args }) => {
  $('title').textContent = `Loading release ${String(args?.release ?? '')}...`
}
app.ontoolresult = (result) => {
  if (result.structuredContent) render(result.structuredContent as Checklist)
}
app.onhostcontextchanged = applyHostContext

$('summary').addEventListener('click', () => {
  app.sendMessage({ role: 'user', content: [{ type: 'text', text: `Summarize what is still open for release ${release}.` }] })
})
$('process').addEventListener('click', (event) => {
  event.preventDefault()
  app.openLink({ url: 'https://example.com/release-process' })
})

// 2. Then connect: this runs the ui/initialize handshake with the host.
app.connect().then(applyHostContext)

What each call does:

  • new App({ name, version }) creates the view's side of the protocol.
  • Handlers before connect(). The host may send the tool input and the tool result right after the handshake. A handler set later can miss them, and the view stays on its loading state. Claude's quickstart and the SDK docs both insist on this order.
  • ontoolinput receives the complete arguments the model sent, before the result arrives. Use it for a loading state. (ontoolinputpartial receives them while they stream.)
  • ontoolresult receives the tool's result: content and structuredContent. This is where you draw.
  • callServerTool({ name, arguments }) calls a tool on your server through the host and returns its result. It is how the view gets fresh data or changes something without a new model turn.
  • sendMessage posts a message into the conversation as the user, so the model answers it. Use it for "ask about this" buttons.
  • openLink({ url }) asks the host to open a URL. The host decides how to open it and may refuse, in which case the result has isError: true. Use it instead of letting the iframe navigate.
  • getHostContext() returns what the host said about itself at initialization: theme, CSS variables in styles.variables, displayMode, locale, timeZone and more. applyDocumentTheme and applyHostStyleVariables apply the theme and the host's variables to the document, and onhostcontextchanged fires when they change (for example, the user switches to dark mode). The theming guide goes further.

A note for v2: in 2.x the on* setters are marked deprecated in favour of app.addEventListener('toolresult', handler), which lets you attach several listeners and remove them. The setters still work, and they are what both quickstarts use, so the code above runs on 1.x and 2.x alike.

Bundle the view into one HTML file

The resource returns one HTML document, and the host loads it in a sandbox with a strict CSP. By default the view can only run inline scripts and scripts from its own origin, and it cannot connect anywhere (MCP Apps CSP explained). The simplest way to respect that is to inline everything: JavaScript, CSS and the SDK itself. The official quickstart does it with Vite and vite-plugin-singlefile:

ts
// vite.config.ts
import { defineConfig } from 'vite'
import { viteSingleFile } from 'vite-plugin-singlefile'

export default defineConfig({
  plugins: [viteSingleFile()],
  build: { outDir: 'dist', rollupOptions: { input: 'view.html' } },
})

npx vite build writes dist/view.html with the script inlined, which is the file server.ts reads. The same setup works with React, Vue, Svelte, Preact or Solid: the SDK repo has a starter template for each, and @modelcontextprotocol/ext-apps/react adds hooks such as useApp and useHostStyles.

Or skip the build: load App from a CDN

For a small view you can skip the bundler, as Claude's quickstart does. Write the HTML as a string in the server, import a prebuilt bundle of App from a CDN, and declare that CDN in the resource's CSP so the sandbox lets the script load:

ts
const VIEW_HTML = `<!doctype html>
<html><head><meta charset="utf-8"><meta name="color-scheme" content="light dark"></head>
<body>
<h1 id="title">Waiting for the release...</h1>
<script type="module">
  import { App } from 'https://unpkg.com/@modelcontextprotocol/ext-apps@2.0.3/dist/src/app-with-deps.js'
  const app = new App({ name: 'Release checklist', version: '1.0.0' })
  app.ontoolresult = ({ structuredContent }) => {
    document.getElementById('title').textContent = 'Release ' + structuredContent.release
  }
  await app.connect()
</script>
</body></html>`

registerAppResource(server, 'Release checklist view', VIEW_URI, { description: 'Interactive release checklist' }, async () => ({
  contents: [
    {
      uri: VIEW_URI,
      mimeType: RESOURCE_MIME_TYPE,
      text: VIEW_HTML,
      _meta: { ui: { csp: { resourceDomains: ['https://unpkg.com'] } } },
    },
  ],
}))

app-with-deps.js is the build of App with its dependencies included, so it runs without an import map. Pin the version in the URL to the one you installed (Claude's quickstart uses 1.7.5 with ext-apps v1). For production, Anthropic recommends serving the script from your own origin or bundling it, and listing only that origin in resourceDomains.

Run it and test it

Build the view, then point an MCP Apps host at the server. For Claude Desktop with stdio, add the server to its configuration file:

json
{
  "mcpServers": {
    "release-checklist": {
      "command": "npx",
      "args": ["tsx", "/absolute/path/to/release-checklist/main.ts"]
    }
  }
}

Restart Claude Desktop, enable the server in a chat and ask "Show me the checklist for release v2.4". The model calls show_checklist, the view appears, and ticking an item calls toggle_item through the host.

For Claude on the web or ChatGPT, serve over HTTP at a public HTTPS URL (a tunnel is fine while you iterate) and add it as a custom connector in Claude, or with developer mode in ChatGPT (OpenAI steps). The add a UI to your MCP server guide has both procedures, and MCP Apps clients lists which hosts render views.

If the tool runs but the view stays blank, the cause is almost always one of three: a handler registered after connect(), a script or image from an origin missing from the CSP, or a resource URI that does not match the tool's _meta.ui.resourceUri.

Skip the boilerplate

Everything above is the right path when the view is your product: a custom editor, a game, an interaction nobody has built. A lot of MCP Apps are simpler than that. They show data the model already has: a table, a chart, a board, a few KPIs. For those, writing the view, the theme handling, the CSP and the build is work you repeat every time.

That is the case Widgetry is for. You start from one of thirteen templates (such as table, area chart or kanban), adjust the data schema, the sample data and the design in an editor whose preview is a real MCP Apps host, and publish. Your server then registers it with registerAppTool and registerAppResource, exactly as in this tutorial, reading the widget from its live URL with a read-only key, so a change you publish reaches your server without a deploy. If you prefer to own the files, you download the HTML document and the manifest instead. The add a UI to your MCP server guide shows that code line by line, and MCP Apps vs MCP-UI vs Apps SDK explains how this standard relates to the older options.

Your first widget, in the chat in minutes

Pick a template, drop in your data and see it as ChatGPT or Claude will show it. Then link it from your MCP server, or let your agent build the next one.