MCP App Build Guide — Knowledge Hub
MCP App Build Guide — Knowledge Hub
Section titled “MCP App Build Guide — Knowledge Hub”Updated: 22/07/2026 (S491 — app names synced to the current mcp-apps/
inventory: bid-dashboard renamed form-dashboard per the bid→form
terminology migration; prior 07/04/2026)
Reference for building, testing, and deploying MCP Apps in this project. First app built: Session 72 (Coverage Matrix). Four apps currently live: Coverage Matrix, Form Dashboard, Reorient Me, and Intelligence Feed.
Scaffolding shortcut: the
create-mcp-appskill automates most of the steps in section “Creating a New MCP App”. Use it as the default path; this guide is the human-readable reference for what the skill is doing under the hood and the fall-back when the skill is not used.
Architecture
Section titled “Architecture”mcp-apps/ coverage-matrix/ # Vite + vanilla TS → single HTML app.html # Entry point (lang="en-GB") src/app.ts # App logic (imports @modelcontextprotocol/ext-apps) src/types.ts # TypeScript interfaces src/styles.css # CSS with host variable fallbacks vite.config.ts # vite-plugin-singlefile config package.json # App-specific deps tsconfig.json # ES2022, bundler moduleResolution dist/app.html # Built output (gitignored) form-dashboard/ # Same layout as coverage-matrix reorient-me/ # Same layout; adds dompurify + marked for Markdown rendering intelligence-feed/ # Same layout as coverage-matrix
lib/mcp/ tools/apps.ts # registerAppTool() for trigger tools (show_coverage_matrix, show_bid_dashboard, show_reorient_me, show_intelligence_feed) resources.ts # registerAppResource() for ui:// resources app-bundles.ts # Auto-generated HTML string constants (committed) formatters/ # Barrel directory — one file per tool category index.ts # Re-exports everything dashboard.ts # BidDashboardData, CoverageMatrixData, ReorientationData, etc. intelligence.ts # IntelligenceSummaryData, IntelligenceArticle …
scripts/ bundle-mcp-apps.ts # Reads dist/app.html → generates app-bundles.tsBuild Commands
Section titled “Build Commands”# Build all MCP apps (Vite + bundle generation)bun run build:mcp-apps
# Build a specific app manuallycd mcp-apps/coverage-matrix && bun install && INPUT=app.html bunx vite build
# Regenerate app-bundles.ts only (after manual Vite build)bun scripts/bundle-mcp-apps.tsThe build:mcp-apps script walks each app directory in turn (bun install +
INPUT=app.html bunx vite build) and then runs scripts/bundle-mcp-apps.ts to
regenerate lib/mcp/app-bundles.ts. The bundler script is the single source of
truth for which apps exist — see “Update bundle script” below.
Creating a New MCP App
Section titled “Creating a New MCP App”1. Scaffold the Vite project
Section titled “1. Scaffold the Vite project”mkdir -p mcp-apps/my-app/srcRequired files:
package.json— deps:@modelcontextprotocol/ext-apps(pin to a specific version, e.g.1.3.2— the existing apps all pin1.3.2exactly), devDeps:typescript ^5,vite ^6,vite-plugin-singlefile ^2vite.config.ts—viteSingleFile()plugin,INPUTenv var for entrytsconfig.json— ES2022, bundler moduleResolution,outDir: dist,include: ["src/**/*.ts"]app.html—lang="en-GB", module script tosrc/app.tssrc/app.ts— main app logicsrc/styles.css— stylingsrc/types.ts— client-side mirror of the server-side formatter interface (must satisfy the correspondinglib/mcp/formatters/*.tsinterface — see__tests__/mcp/mcp-app-contracts.test.ts)
2. App lifecycle (CRITICAL — register ALL handlers BEFORE connect)
Section titled “2. App lifecycle (CRITICAL — register ALL handlers BEFORE connect)”import { App, applyDocumentTheme, applyHostStyleVariables, applyHostFonts, type McpUiHostContext,} from '@modelcontextprotocol/ext-apps';
const app = new App({ name: 'My App', version: '1.0.0' });
// Host theme integrationfunction handleHostContextChanged(ctx: McpUiHostContext): void { if (ctx.theme) applyDocumentTheme(ctx.theme); if (ctx.styles?.variables) applyHostStyleVariables(ctx.styles.variables); if (ctx.styles?.css?.fonts) applyHostFonts(ctx.styles.css.fonts);}
// Register ALL handlers BEFORE connect()app.ontoolresult = (result) => { /* render from result.structuredContent */};app.ontoolinput = () => { /* show loading during streaming input */};app.ontoolcancelled = () => { /* show cancelled state */};app.onerror = (error) => { /* show error state */};app.onhostcontextchanged = handleHostContextChanged;app.onteardown = async () => { /* cleanup */ return {};};
// Connect and apply initial host contextapp.connect().then(() => { const ctx = app.getHostContext(); if (ctx) handleHostContextChanged(ctx);});3. CSS host variable pattern
Section titled “3. CSS host variable pattern”:root { /* SDK variables → project fallbacks */ --app-bg: var(--color-background-primary, oklch(0.98 0.008 48)); --app-text: var(--color-text-primary, oklch(0.25 0.016 48));}
/* Host-driven dark mode (applyDocumentTheme sets data-theme) */[data-theme='dark'] { --app-bg: var(--color-background-primary, oklch(0.18 0.014 48));}
/* System preference fallback */@media (prefers-color-scheme: dark) { :root:not([data-theme='light']) { /* same dark tokens */ }}4. Register the trigger tool (in lib/mcp/tools/apps.ts)
Section titled “4. Register the trigger tool (in lib/mcp/tools/apps.ts)”import { defineAppTool, READ_ONLY_ANNOTATIONS, getExtAppsServer,} from './shared';
const { registerAppTool } = await getExtAppsServer(); // lazy import!
defineAppTool( registerAppTool, server, 'show_my_thing', { title: 'Show My Thing', description: 'Display an interactive view...', inputSchema: { /* zod params */ }, annotations: READ_ONLY_ANNOTATIONS, _meta: { ui: { resourceUri: 'ui://my-app/app.html' } }, }, async (args, extra) => { // Aggregate data, return content + structuredContent return { content: [{ type: 'text', text: markdown }], structuredContent: toStructuredContent(data), }; },);Every trigger tool goes through toStructuredContent() from
lib/mcp/tools/shared.ts (the MCP SDK’s index signature rejects plain objects).
Markdown content is truncated to 10,000 chars via truncateResponse() from
lib/mcp/formatters.
5. Register the resource (in lib/mcp/resources.ts)
Section titled “5. Register the resource (in lib/mcp/resources.ts)”const { registerAppResource, RESOURCE_MIME_TYPE } = await import('@modelcontextprotocol/ext-apps/server');
registerAppResource( server, 'My App', 'ui://my-app/app.html', { mimeType: RESOURCE_MIME_TYPE }, async () => { const { MY_APP_HTML } = await getAppBundles(); if (!MY_APP_HTML) return { contents: [{ uri: '...', mimeType: 'text/plain', text: 'Not built' }], }; return { contents: [ { uri: 'ui://my-app/app.html', mimeType: RESOURCE_MIME_TYPE, text: MY_APP_HTML, }, ], }; },);6. Update bundle script
Section titled “6. Update bundle script”Add the new app to the APPS array in scripts/bundle-mcp-apps.ts. The current
shape (four apps) is:
const APPS: AppConfig[] = [ { name: 'coverage-matrix', constName: 'COVERAGE_MATRIX_HTML', htmlPath: join( PROJECT_ROOT, 'mcp-apps', 'coverage-matrix', 'dist', 'app.html', ), }, { name: 'form-dashboard', constName: 'BID_DASHBOARD_HTML', htmlPath: join( PROJECT_ROOT, 'mcp-apps', 'form-dashboard', 'dist', 'app.html', ), }, { name: 'reorient-me', constName: 'REORIENT_ME_HTML', htmlPath: join(PROJECT_ROOT, 'mcp-apps', 'reorient-me', 'dist', 'app.html'), }, { name: 'intelligence-feed', constName: 'INTELLIGENCE_FEED_HTML', htmlPath: join( PROJECT_ROOT, 'mcp-apps', 'intelligence-feed', 'dist', 'app.html', ), },];The bundler emits export const XXX_HTML: string | null = null; for any app
whose dist/app.html is missing, so partial builds don’t break type-checking of
resources.ts.
7. Build and verify
Section titled “7. Build and verify”bun run build:mcp-apps # Builds app + regenerates bundlebun run build # Next.js build (imports lib/mcp/app-bundles.ts)bun run test # All tests pass (incl. mcp-app-contracts + mcp-fixture-sync)bun run knip # Detects unused exports — catches "build but not wired"bun run test:mcp-eval # MCP eval Layer 1 — protocol compliance (42 checks)Optional but recommended before merges that touch MCP tools, resources, or prompts:
bun run test:mcp-eval:fc # MCP eval Layer 4 — functional correctness (37 checks, live DB)L4 (test:mcp-eval:fc) requires a running dev server (bun dev) and a live
Supabase connection. It is optional for every build but recommended before
merging any change that touches MCP tools, resources, or prompts — it catches
regressions that only surface against real data (RLS bypasses, formatter
mismatches, missing fixtures).
Commit lib/mcp/app-bundles.ts alongside the app source so Vercel has the
inlined HTML during deployment.
Drill-down Interactions
Section titled “Drill-down Interactions”Use app.callServerTool() to call existing MCP tools from within the app:
const result = await app.callServerTool({ name: 'search_knowledge_base', arguments: { query: 'security', domain: 'Security', limit: 15 },});const items = result.structuredContent?.results ?? [];Vercel Deployment
Section titled “Vercel Deployment”- App HTML is inlined as string constants in
lib/mcp/app-bundles.ts - No filesystem reads at runtime — works on Vercel serverless
app-bundles.tsis committed to git (Vercel clones the repo)- Build command:
bun run vercel-buildchainsbun run build:mcp-apps && next build. You can also use the defaultnext buildand rely on the committedapp-bundles.ts— both work.
Testing with Claude Desktop
Section titled “Testing with Claude Desktop”- Start dev server:
bun dev - Create tunnel:
npx cloudflared tunnel --url http://localhost:3000 - Add tunnel URL as custom connector in Claude Desktop (Settings → Connectors)
- Ask Claude about coverage → it calls
show_coverage_matrix→ app renders inline
Testing with basic-host (local, no Claude Desktop needed)
Section titled “Testing with basic-host (local, no Claude Desktop needed)”git clone --depth 1 https://github.com/modelcontextprotocol/ext-apps.git /tmp/mcp-ext-appscd /tmp/mcp-ext-apps/examples/basic-host && npm installSERVERS='["http://localhost:3000/api/mcp/mcp"]' npm start# Open http://localhost:8080, select show_coverage_matrix, click CallKey Rules
Section titled “Key Rules”- Lazy imports: Always lazy-load
@modelcontextprotocol/ext-apps/serverintools/apps.tsandresources.ts(viagetExtAppsServer()inlib/mcp/tools/shared.tsandawait import(...)inresources.ts) - Handlers before connect: Register ALL handlers BEFORE
app.connect() - textContent only: Never use innerHTML — prevents XSS (reorient-me is the
exception because it renders trusted Markdown via
dompurify+marked) - No localStorage: Sandboxed iframe has no storage access
- UK English:
lang="en-GB", “Ageing” not “Aging”, “colour” not “color” - WCAG 2.1 AA: aria attributes, keyboard navigation, never colour alone
- vite-plugin-singlefile v2: Use
"^2"not"^3"inpackage.json - Vite v6: Pin
viteto^6to match the other apps - INPUT env var:
INPUT=app.html bunx vite build - Pin ext-apps: All existing apps pin
@modelcontextprotocol/ext-appsto1.3.2exactly. Keep new apps on the same version to avoid lifecycle drift between apps
Gotchas
Section titled “Gotchas”-
tsconfig.json exclusion:
mcp-apps/MUST be in the roottsconfig.jsonexcludearray. Each Vite app has its own tsconfig and dependencies (vite-plugin-singlefile, vite). If not excluded, Next.js tries to type-check the Vite config files duringnext buildand fails with “Cannot find module ‘vite-plugin-singlefile’”. -
MCP connector tool list is dynamic: When you deploy to Vercel, the connector automatically serves the updated tool list on the next
tools/listrequest. No user action needed — Claude Desktop re-fetches tools on each new conversation. -
registerToolsandregisterResourcesare async: They were made async in S72 to support lazy-loaded ext-apps imports. The route handler atapp/api/mcp/[transport]/route.tsawaits both. -
app-bundles.ts must be committed: The generated HTML bundle file is committed to git so Vercel has it during deployment. It is NOT gitignored. The
mcp-apps/*/dist/directories ARE gitignored (built artefacts). The combined file is around 500 KB — changes to any MCP App require rebuilding (bun run build:mcp-apps) and committing the updatedapp-bundles.ts. -
Host theme application order: Call
applyDocumentThemefirst (setsdata-themeattribute andcolor-scheme), thenapplyHostStyleVariables(sets CSS custom properties), thenapplyHostFonts(injects font CSS). Apply both inonhostcontextchangedhandler AND afterconnect()resolves (for the initial context). -
CSP for network requests: MCP Apps HTML has no same-origin server. ALL network requests (even to localhost) require CSP configuration via
_meta.ui.cspin thecontents[]objects returned byregisterAppResource’s read callback. Our current apps don’t make network requests (all data via tool results), so no CSP is needed yet. -
Vercel build command: Set to
bun run vercel-buildwhich chains MCP app Vite builds → bundle generation → Next.js build. Or commitapp-bundles.tsand use the standardnext build. -
mcp-handlerbreaks on Vercel: The MCP route handler uses the MCP SDK’sWebStandardStreamableHTTPServerTransportdirectly, notcreateMcpHandler. Fresh server + transport per request.mcp-handleris only used for the.well-knownendpoint. This affects the route, not the apps themselves, but it’s the reasonregisterResourcesandregisterToolsinresources.tsandtools/index.tsmust be safe to call repeatedly on fresh servers. -
Contract tests enforce server/client type alignment:
__tests__/mcp/mcp-app-contracts.test.tsbuilds fixture objects that must satisfy BOTH the server-sidelib/mcp/formatters/*.tsinterfaces and the client-sidemcp-apps/*/src/types.tsmirrors. If you change either side, update the other and runbun run testbefore committing. -
Fixture-sync test enforces tool-name sync:
__tests__/mcp/mcp-fixture-sync.test.tsscanslib/mcp/tools/*.tsforregisterTool('name', ...)andregisterAppTool(server, 'name', ...)patterns and compares the result againstCANONICAL_TOOL_NAMESinscripts/mcp-eval/fixtures.ts. When adding a trigger tool, update the fixtures file too or the test fails.
Bundled Asset Validation
Section titled “Bundled Asset Validation”When bundling assets (MCP Apps, Plugins) as string constants, it is easy for them to drift from the codebase (e.g. taxonomy changes).
Pattern:
- Create a
validate()function in your bundle script (e.g.,scripts/bundle-plugin.ts). - Have it compare the canonical source of truth (e.g.,
lib/validation/schemas.ts) with the markers in the assets. - Call
validate()at the start ofmain(). - Fail the build if validation fails, prompting the user to run a sync script.