MCP Apps
Show a Vue card for an MCP tool with the official MCP Apps SDK.
An MCP App is an HTML card that a host, such as ChatGPT or Claude, shows in a
sandboxed iframe next to a tool result. Better Convex has no MCP Apps API. Use
the official @modelcontextprotocol/ext-apps package directly:
registerAppResourceregisters the card'sui://resource on the server thatconfigureServerreceives;registerAppToolregisters the tool that shows the card. Pass it a tool fromdefineMcpTool, so the tool keeps its annotations, scopes, and error projection;- its
Appclass connects the card to the host.
This recipe adds a notes card to the application from
Add MCP to your application. It uses
ctx, principal, server, tools, and internal.notes.listNotes from
that page.
Install the packages
pnpm add @modelcontextprotocol/ext-apps@2.0.0 @modelcontextprotocol/client@2.1.0 @modelcontextprotocol/core@2.1.0
pnpm add -D vite @vitejs/plugin-vue vite-plugin-singlefileUse @modelcontextprotocol/ext-apps 2.x. Its server helpers accept the
McpServer from @modelcontextprotocol/server 2.x, which
@lupinum/better-convex-mcp uses. The 1.x helpers expect a server from the
older @modelcontextprotocol/sdk 1.x.
Build the card as one HTML file
Build the Vue card with Vite and vite-plugin-singlefile. The plugin inlines
all JavaScript and CSS into one index.html, so the card needs no hosting and
no network access.
import { fileURLToPath } from 'node:url'
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
import { viteSingleFile } from 'vite-plugin-singlefile'
export default defineConfig({
root: fileURLToPath(new URL('.', import.meta.url)),
plugins: [vue(), viteSingleFile()],
build: { outDir: 'dist', emptyOutDir: true },
})<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<div id="app"></div>
<script type="module" src="./main.ts"></script>
</body>
</html>import { createApp } from 'vue'
import NotesCard from './NotesCard.vue'
createApp(NotesCard).mount('#app')Import the built file into Convex as a string. This script writes it into a TypeScript module:
import { mkdir, readFile, writeFile } from 'node:fs/promises'
const html = await readFile('mcp-ui/dist/index.html', 'utf8')
await mkdir('convex/mcp', { recursive: true })
await writeFile(
'convex/mcp/notesCardHtml.ts',
`// Generated by scripts/embed-mcp-ui.mjs. Do not edit.\nexport const NOTES_CARD_HTML = ${JSON.stringify(html)}\n`,
){
"scripts": {
"build:mcp-ui": "vite build --config mcp-ui/vite.config.ts && node scripts/embed-mcp-ui.mjs"
}
}Run pnpm build:mcp-ui before you run pnpm exec better-convex convex dev or
pnpm exec better-convex convex deploy.
Connect the card to the host
Create one App for the lifetime of the iframe. Add listeners before you call
connect(), so the card does not miss the first tool result. autoResize
reports the card height to the host, so the iframe fits the content.
<script setup lang="ts">
import { App } from '@modelcontextprotocol/ext-apps'
import { onMounted, ref } from 'vue'
interface Note {
id: string
title: string
}
const notes = ref<Note[]>([])
const hasMore = ref(false)
const connectFailed = ref(false)
const app = new App({ name: 'notes-card', version: '1.0.0' }, {}, { autoResize: true })
app.addEventListener('toolresult', (result) => {
const data = (result.structuredContent ?? {}) as { notes?: unknown; nextCursor?: unknown }
notes.value = Array.isArray(data.notes)
? data.notes.filter(
(note): note is Note => typeof note?.id === 'string' && typeof note?.title === 'string',
)
: []
// A host can drop null fields. A missing nextCursor means the same as null.
hasMore.value = typeof data.nextCursor === 'string'
})
onMounted(() => {
app.connect().catch(() => {
connectFailed.value = true
})
})
</script>
<template>
<p v-if="connectFailed">The notes card could not connect.</p>
<ul v-else>
<li v-for="note in notes" :key="note.id">{{ note.title }}</li>
</ul>
<p v-if="hasMore">Ask for more notes to see the next page.</p>
</template>The card has no Convex client and no credentials. It receives data only from the tool result that the host sends.
Register the resource and the tool
Add this code to convex/mcp.ts. Put the constants at module level and the
registrations inside registerNoteTools, after list_notes.
import { defineMcpTool } from '@lupinum/better-convex-mcp'
import {
RESOURCE_MIME_TYPE,
registerAppResource,
registerAppTool,
} from '@modelcontextprotocol/ext-apps/server'
import { NOTES_CARD_HTML } from './mcp/notesCardHtml'
// Change the version when the card changes. Keep the older URIs registered.
const NOTES_CARD_URI = 'ui://notes/list-v2.html'
const NOTES_CARD_URIS = [NOTES_CARD_URI, 'ui://notes/list-v1.html']
const notesCardMeta = {
ui: {
csp: { connectDomains: [], resourceDomains: [] },
prefersBorder: true,
},
}
// Inside registerNoteTools(ctx, { principal, server, tools }):
for (const uri of NOTES_CARD_URIS) {
registerAppResource(server, 'Notes card', uri, { _meta: notesCardMeta }, async () => ({
contents: [{ uri, mimeType: RESOURCE_MIME_TYPE, text: NOTES_CARD_HTML, _meta: notesCardMeta }],
}))
}
const showNotes = defineMcpTool(tools, {
name: 'show_notes',
title: 'Show my notes',
description:
'Show your newest notes as a card. Use list_notes to read note data. Does not change notes.',
risk: 'read',
scopes: ['notes:read'],
ui: { resourceUri: NOTES_CARD_URI },
inputSchema: z.object({}).strict(),
outputSchema: z.object({
notes: z.array(z.object({ id: z.string(), title: z.string() })).max(20),
nextCursor: z.string().nullable(),
}),
handler: async () => {
const result = await ctx.runQuery(internal.notes.listNotes, { cursor: null, principal })
return {
content: [{ type: 'text', text: `Showing ${result.notes.length} notes.` }],
structuredContent: result,
}
},
})
registerAppTool(server, showNotes.name, showNotes.config, showNotes.handler)defineMcpTool writes the card URI to _meta.ui.resourceUri and to the older
ui/resourceUri key that registerAppTool also writes. Both registration
paths therefore list the same tool. The internal function still calls
auth.requireMcpPrincipal, so a card tool has the same authorization as any
other tool. A denial reaches the card as a structured tool error.
Follow the host rules
- Keep
ui://URIs stable. A host can cache a card by its URI. Put a version in the URI and change it only when the card changes. Keep the older URIs registered with the current HTML, so conversations that started before a deploy still show a card. - Split the show tool from the read tool. A host shows the card each time
a tool with
_meta.ui.resourceUriruns. Keeplist_notesa plain read tool withoutui, so the model can read data without a card. Give the card its own tool, such asshow_notes. - Treat a missing field like
null. ChatGPT can dropnullfields fromstructuredContentbefore the card receives it. The card must not require a field that can benull. - Declare the CSP. Set
_meta.ui.cspeven when the lists are empty. Empty lists block all network access from the card. Set_meta.ui.domainonly when a host asks for a dedicated card origin, and use the format that host documents. - Keep the text fallback. Return
contenttext andstructuredContentfrom the show tool. A client without MCP Apps support shows the text, and the model reads it. - Keep the HTML static. Send data only through the tool result. Do not write data, tokens, or secrets into the HTML.
Authorization does not change
The card is presentation only. A button in the card grants no permission. When
the card calls app.callServerTool(), the host sends an ordinary MCP tool
call. The same OAuth access token check and the same Convex authorization run
as for a call from the model.