Skip to main content

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:

  • registerAppResource registers the card's ui:// resource on the server that configureServer receives;
  • registerAppTool registers the tool that shows the card. Pass it a tool from defineMcpTool, so the tool keeps its annotations, scopes, and error projection;
  • its App class 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

bash
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-singlefile

Use @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.

mcp-ui/vite.config.ts
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 },
})
mcp-ui/index.html
<!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>
mcp-ui/main.ts
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:

scripts/embed-mcp-ui.mjs
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`,
)
package.json
{
  "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.

mcp-ui/NotesCard.vue
<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.

convex/mcp.ts
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.resourceUri runs. Keep list_notes a plain read tool without ui, so the model can read data without a card. Give the card its own tool, such as show_notes.
  • Treat a missing field like null. ChatGPT can drop null fields from structuredContent before the card receives it. The card must not require a field that can be null.
  • Declare the CSP. Set _meta.ui.csp even when the lists are empty. Empty lists block all network access from the card. Set _meta.ui.domain only when a host asks for a dedicated card origin, and use the format that host documents.
  • Keep the text fallback. Return content text and structuredContent from 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.