Skip to content

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-app skill 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.

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.ts
Terminal window
# Build all MCP apps (Vite + bundle generation)
bun run build:mcp-apps
# Build a specific app manually
cd 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.ts

The 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.

Terminal window
mkdir -p mcp-apps/my-app/src

Required files:

  • package.json — deps: @modelcontextprotocol/ext-apps (pin to a specific version, e.g. 1.3.2 — the existing apps all pin 1.3.2 exactly), devDeps: typescript ^5, vite ^6, vite-plugin-singlefile ^2
  • vite.config.tsviteSingleFile() plugin, INPUT env var for entry
  • tsconfig.json — ES2022, bundler moduleResolution, outDir: dist, include: ["src/**/*.ts"]
  • app.htmllang="en-GB", module script to src/app.ts
  • src/app.ts — main app logic
  • src/styles.css — styling
  • src/types.ts — client-side mirror of the server-side formatter interface (must satisfy the corresponding lib/mcp/formatters/*.ts interface — 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 integration
function 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 context
app.connect().then(() => {
const ctx = app.getHostContext();
if (ctx) handleHostContextChanged(ctx);
});
: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,
},
],
};
},
);

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.

Terminal window
bun run build:mcp-apps # Builds app + regenerates bundle
bun 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:

Terminal window
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.

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 ?? [];
  • App HTML is inlined as string constants in lib/mcp/app-bundles.ts
  • No filesystem reads at runtime — works on Vercel serverless
  • app-bundles.ts is committed to git (Vercel clones the repo)
  • Build command: bun run vercel-build chains bun run build:mcp-apps && next build. You can also use the default next build and rely on the committed app-bundles.ts — both work.
  1. Start dev server: bun dev
  2. Create tunnel: npx cloudflared tunnel --url http://localhost:3000
  3. Add tunnel URL as custom connector in Claude Desktop (Settings → Connectors)
  4. 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)”
Terminal window
git clone --depth 1 https://github.com/modelcontextprotocol/ext-apps.git /tmp/mcp-ext-apps
cd /tmp/mcp-ext-apps/examples/basic-host && npm install
SERVERS='["http://localhost:3000/api/mcp/mcp"]' npm start
# Open http://localhost:8080, select show_coverage_matrix, click Call
  • Lazy imports: Always lazy-load @modelcontextprotocol/ext-apps/server in tools/apps.ts and resources.ts (via getExtAppsServer() in lib/mcp/tools/shared.ts and await import(...) in resources.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" in package.json
  • Vite v6: Pin vite to ^6 to match the other apps
  • INPUT env var: INPUT=app.html bunx vite build
  • Pin ext-apps: All existing apps pin @modelcontextprotocol/ext-apps to 1.3.2 exactly. Keep new apps on the same version to avoid lifecycle drift between apps
  • tsconfig.json exclusion: mcp-apps/ MUST be in the root tsconfig.json exclude array. 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 during next build and 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/list request. No user action needed — Claude Desktop re-fetches tools on each new conversation.

  • registerTools and registerResources are async: They were made async in S72 to support lazy-loaded ext-apps imports. The route handler at app/api/mcp/[transport]/route.ts awaits 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 updated app-bundles.ts.

  • Host theme application order: Call applyDocumentTheme first (sets data-theme attribute and color-scheme), then applyHostStyleVariables (sets CSS custom properties), then applyHostFonts (injects font CSS). Apply both in onhostcontextchanged handler AND after connect() 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.csp in the contents[] objects returned by registerAppResource’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-build which chains MCP app Vite builds → bundle generation → Next.js build. Or commit app-bundles.ts and use the standard next build.

  • mcp-handler breaks on Vercel: The MCP route handler uses the MCP SDK’s WebStandardStreamableHTTPServerTransport directly, not createMcpHandler. Fresh server + transport per request. mcp-handler is only used for the .well-known endpoint. This affects the route, not the apps themselves, but it’s the reason registerResources and registerTools in resources.ts and tools/index.ts must be safe to call repeatedly on fresh servers.

  • Contract tests enforce server/client type alignment: __tests__/mcp/mcp-app-contracts.test.ts builds fixture objects that must satisfy BOTH the server-side lib/mcp/formatters/*.ts interfaces and the client-side mcp-apps/*/src/types.ts mirrors. If you change either side, update the other and run bun run test before committing.

  • Fixture-sync test enforces tool-name sync: __tests__/mcp/mcp-fixture-sync.test.ts scans lib/mcp/tools/*.ts for registerTool('name', ...) and registerAppTool(server, 'name', ...) patterns and compares the result against CANONICAL_TOOL_NAMES in scripts/mcp-eval/fixtures.ts. When adding a trigger tool, update the fixtures file too or the test fails.


When bundling assets (MCP Apps, Plugins) as string constants, it is easy for them to drift from the codebase (e.g. taxonomy changes).

Pattern:

  1. Create a validate() function in your bundle script (e.g., scripts/bundle-plugin.ts).
  2. Have it compare the canonical source of truth (e.g., lib/validation/schemas.ts) with the markers in the assets.
  3. Call validate() at the start of main().
  4. Fail the build if validation fails, prompting the user to run a sync script.