MCP Apps Template Server
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@MCP Apps Template Serverecho 'Hello, world!'"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
MCP Apps Template
A well-architected starter template demonstrating best practices for building MCP Apps using the Model Context Protocol (MCP) with React widgets. It leverages TypeScript, Tailwind CSS v4, Pino logging, Storybook, and Vitest for a robust development experience.
Features
MCP Server - Node.js server with
McpServerand MCP Apps helpersEcho Tool - Example tool with Zod validation and UI binding
React Widgets - Interactive Echo component with MCP Apps
AppAPI demoDisplay Modes - Inline, picture-in-picture, and fullscreen with runtime toggling via
requestDisplayMode()App API Demo -
callServerTool,openLink,sendMessage,updateModelContextshowcased in the Echo widgetStateless MCP HTTP - Per-request server factories with no transport sessions or session affinity
Inline Widget Assets - Self-contained HTML mode for hosts that sandbox iframes (e.g. Claude.ai)
Container Dimensions - Responsive widget sizing using host-provided
containerDimensionsMock App - Drop-in
createMockApp()helper for testing and Storybook without a live MCP connectionPino Logging - Structured logging with pretty printing in development
TypeScript - Strict mode with ES2023 target
Tailwind CSS v4 - Modern styling with dark mode support
Storybook - Component development with a11y addon
Testing - Vitest for server and widgets with accessibility checks
Build Optimizations - Parallel builds, content hashing, compression
Docker - Multi-stage builds with health checks
Production Ready - Stateless transport, graceful shutdown, and error handling
Related MCP server: MCP Apps Server
Architecture
graph TD
A[MCP Host] -->|HTTPStreamable| B[MCP Server<br/>Node.js + Express]
B -->|_meta.ui.resourceUri| C[App View<br/>React in iframe]
B -.-> B1[Echo Tool]
B -.-> B2[Resource Registration]
B -.-> B3[text/html;profile=mcp-app<br/>MIME type]
C -.-> C1[Receives App.ontoolresult]
C -.-> C2[callServerTool, openLink,<br/>sendMessage, updateModelContext]
C -.-> C3[Theme, displayMode, safeArea,<br/>containerDimensions]
style A fill:#e1f5ff
style B fill:#fff4e6
style C fill:#f3e5f5Quick Start
Setup time: ~5 minutes (first time)
Prerequisites
Node.js 24+ (required for ES2023 support and native type stripping)
Verify:
node -v(should show v24.0.0 or higher)
npm 11+ (ships with Node 24)
Verify:
npm -v(should show v10.0.0 or higher)
Supported platforms: macOS, Linux, Windows (via WSL2)
Installation & Setup
git clone https://github.com/pomerium/mcp-app-typescript-template your-mcp-app
cd your-mcp-app
npm install
npm run devThis starts everything you need — no manual build step:
MCP Server:
http://localhost:8080Widget dev server (live modules + HMR):
http://localhost:4444Background watch build: keeps
assets/fresh so hosts that need self-contained HTML (like Claude.ai) always get an up-to-date inlined widget
The server picks the right widget HTML per request: hosts that can load external assets get live dev modules with hot module replacement, and hosts that can't (Claude.ai, plus any client that doesn't identify itself) automatically get fully inlined HTML rebuilt on every file change. See How Development Serving Works.
Note: The MCP server is a backend service. To test it, follow the host connection steps below (ChatGPT example) or use
npm run inspectfor local testing.
You should see output indicating both servers are running successfully:
❯ npm run dev
> mcp-app-typescript-template@1.0.0 dev
> concurrently -n server,widgets,build "npm run dev:server" "npm run dev:widgets" "npm run dev:widgets:build"
[widgets] > vite
[server] > tsx watch src/server.ts
[build] > vite build --watch
[widgets] Found 1 widget(s):
[widgets] - echo
[widgets]
[widgets] VITE v8.2.1 ready in 310 ms
[widgets] ➜ Local: http://localhost:4444/
[build] ✓ 1936 modules transformed.
[server] [12:45:12] INFO: Starting MCP App Template server
[server] port: 8080
[server] nodeEnv: "development"
[server] [12:45:12] INFO: Server started successfully
[server] mcpEndpoint: "http://localhost:8080/mcp"
[server] healthEndpoint: "http://localhost:8080/health"Connect to a Host (ChatGPT example)
To test your app in ChatGPT, you need to expose your local server publicly. The fastest way is using Pomerium's SSH tunnel:
1. Create a public tunnel (in a new terminal, keep npm run dev running):
ssh -R 0 pom.runFirst-time setup:
You'll see a sign-in URL in your terminal:
Please sign in with hosted to continue https://data-plane-us-central1-1.dataplane.pomerium.com/.pomerium/sign_in?user_code=some-codeClick the link and sign up
Authorize via the Pomerium OAuth flow
Your terminal will display connection details:

2. Find your public URL:
Look for the Port Forward Status section showing:
Status:
ACTIVE(tunnel is running)Remote:
https://template.first-wallaby-240.pom.run(your unique URL)Local:
http://localhost:8080(your local server)
3. Add to ChatGPT:
Enable MCP apps dev mode in your ChatGPT settings
Go to: Settings → Connectors → Add Connector
Enter your Remote URL +
/mcp, e.g.https://template.first-wallaby-240.pom.run/mcpSave the connector
4. Test it:
Start a new chat in ChatGPT
Add your app to the chat
Send:
echo today is a great dayYou should see the message displayed in an interactive widget

The tunnel stays active as long as the SSH session is running.
Claude.ai: the same tunnel works out of the box — add the connector URL in Claude.ai settings. Claude.ai can't load widget assets from an external dev server, so the template automatically serves it fully inlined widget HTML, rebuilt on every file change (no HMR, but no manual build step either).
Other hosts: Claude Desktop, VS Code, Goose, and other MCP Apps hosts follow the same pattern—add a connector to your /mcp endpoint and refresh after changes.
Optional: Tunnel the Widget Dev Server (HMR through the tunnel)
Hosts that honor the resource CSP (e.g. ChatGPT in dev mode) can load live widget modules — with hot module replacement — through a second tunnel pointed at the widget dev server:
# Second terminal: tunnel the widget dev server (port 4444)
ssh -R 0:localhost:4444 pom.runThen set BASE_URL in .env to that tunnel's public URL and restart npm run dev:
BASE_URL=https://widgets.first-wallaby-240.pom.runWidget HTML, CSP domains (https:// + wss:// for HMR), and Vite's allowed hosts are all derived from BASE_URL automatically. Without BASE_URL, widget assets are served from http://localhost:4444, which only works when the host's iframe runs in a browser on your machine.
Success! What's Next?
Now that your app is working, you can:
Customize the echo tool - Modify the example tool or add your own logic
Create a new widget - Build custom UI components for your tools
Test locally - Use
npm run inspectfor debugging without a hostDeploy to production - Take your app live when ready
Available Commands
Development
# Start everything (server + widget dev server + background watch build)
# Serves HMR modules to hosts that support them, auto-inlined HTML to the rest
npm run dev
# Force inlined assets for every client (rarely needed — npm run dev handles this per client)
npm run dev:inline
# Start only MCP server (watch mode)
npm run dev:server
# Start only widget dev server
npm run dev:widgets
# Test with MCP Inspector
npm run inspectBuilding
# Full production build (widgets + server)
npm run build
# Build only widgets
npm run build:widgets
# Build only server
npm run build:serverTesting
# Run all tests
npm test
# Run server tests only
npm run test:server
# Run widget tests only
npm run test:widgets
# Run tests with coverage
npm run test:coverageCode Quality
# Lint all TypeScript files
npm run lint
# Format code with Prettier
npm run format
# Check formatting without modifying
npm run format:check
# Type check all workspaces
npm run type-checkStorybook
# Run Storybook dev server
npm run storybook
# Build Storybook for production
npm run build:storybookTesting Your App
1. Local Testing with MCP Inspector
npm run inspectThis opens a browser interface to:
List available tools
Test tool invocations
Inspect responses and metadata
Verify widget resources load correctly
2. Connect from ChatGPT
For complete ChatGPT connection instructions, see the Quick Start: Connect to a Host section above.
Already connected? After making code changes:
Settings → Connectors → Your App → Refresh
This reloads tool definitions and metadata
Production Setup:
When deploying to production:
Deploy your server to a public URL (see Production Deployment)
In ChatGPT: Settings → Connectors → Add Connector
Enter your server URL:
https://your-domain.com/mcpTest the
echotool in ChatGPT
Project Structure
mcp-app-template/
├── server/ # MCP server
│ ├── src/
│ │ ├── server.ts # Main server with echo tool
│ │ ├── types.ts # Type definitions
│ ├── tests/
│ │ └── echo-tool.test.ts
│ └── package.json # Server dependencies
│
├── widgets/ # React widgets
│ ├── src/
│ │ ├── widgets/
│ │ │ └── echo.tsx # Widget entry (includes mounting code)
│ │ ├── echo/
│ │ │ ├── Echo.tsx # Shared components
│ │ │ ├── Echo.stories.tsx
│ │ │ └── styles.css
│ │ ├── components/
│ │ │ └── ui/ # ShadCN components
│ │ ├── mocks/
│ │ │ └── mock-app.ts # MCP Apps mock for tests/stories
│ │ └── types/
│ │ └── mcp-app.ts # MCP Apps types for UI wiring
│ ├── .storybook/ # Storybook config
│ └── package.json # Widget dependencies
│
├── assets/ # Asset build artifacts
│ ├── echo.html
│ ├── echo-[hash].js
│ └── echo-[hash].css
│
├── docker/
│ ├── Dockerfile # Multi-stage build
│ └── docker-compose.yml
│
└── package.json # Root workspaceAdding New Tools
1. Define Tool Schema
// server/src/types.ts
export const MyToolInputSchema = z.object({
input: z.string().min(1, 'Input is required'),
});2. Register Tool (with UI)
registerAppTool(
server,
'my_tool',
{
title: 'My Tool',
description: 'Does something cool',
inputSchema: {
type: 'object',
properties: {
input: { type: 'string', description: 'Tool input' },
},
required: ['input'],
},
_meta: {
ui: { resourceUri: 'ui://my-widget' },
},
},
async (args) => {
const input = MyToolInputSchema.parse(args).input;
return {
content: [{ type: 'text', text: 'Result' }],
structuredContent: { result: input },
};
}
);3. Create Widget
Create widgets/src/widgets/my-widget.tsx:
// widgets/src/widgets/my-widget.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from '@modelcontextprotocol/ext-apps';
import { useEffect, useState } from 'react';
function MyWidget() {
const [toolOutput, setToolOutput] = useState(null);
const [theme, setTheme] = useState('light');
useEffect(() => {
const app = new App({ name: 'MyWidget', version: '1.0.0' });
app.ontoolresult = (result) =>
setToolOutput(result.structuredContent ?? null);
app.onhostcontextchanged = (context) => setTheme(context?.theme ?? 'light');
app.connect();
}, []);
return (
<div className={theme === 'dark' ? 'dark' : ''}>
<h1>My Widget</h1>
<pre>{JSON.stringify(toolOutput, null, 2)}</pre>
</div>
);
}
// Mounting code - required at the bottom of each widget file
const rootElement = document.getElementById('my-widget-root');
if (rootElement) {
createRoot(rootElement).render(
<StrictMode>
<MyWidget />
</StrictMode>
);
}4. Register Widget Resource
registerAppResource(
server,
'ui://my-widget',
'ui://my-widget',
{ mimeType: RESOURCE_MIME_TYPE },
async () => ({
contents: [
{
uri: 'ui://my-widget',
mimeType: RESOURCE_MIME_TYPE,
text: await readWidgetHtml('my-widget'),
},
],
})
);5. Build
npm run build:widgets
npm run dev:serverThe build script auto-discovers widgets in widgets/src/widgets/*.{tsx,jsx} and bundles them with their mounting code
Widget Development
Widget Pattern
Widgets include both the component and mounting code:
1. Create widget entry point in widgets/src/widgets/[name].tsx:
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { useEffect, useState } from 'react';
import { App } from '@modelcontextprotocol/ext-apps';
function MyWidget() {
const [toolOutput, setToolOutput] = useState(null);
useEffect(() => {
const app = new App({ name: 'MyWidget', version: '1.0.0' });
app.ontoolresult = (result) =>
setToolOutput(result.structuredContent ?? null);
app.connect();
}, []);
return <div>Widget content</div>;
}
// Mounting code - required
const rootElement = document.getElementById('my-widget-root');
if (rootElement) {
createRoot(rootElement).render(
<StrictMode>
<MyWidget />
</StrictMode>
);
}2. Build discovers and bundles widget:
npm run build:widgets3. Widget available as ui://my-widget
The build system:
Auto-discovers all files in
widgets/src/widgets/*.{tsx,jsx}Bundles the component and mounting code together
Creates content-hashed bundles and HTML templates
MCP Apps App API Reference
Tool Results & Host Context
const app = new App({ name: 'Echo', version: '1.0.0' });
app.ontoolresult = (result) => {
console.log(result.structuredContent);
};
app.onhostcontextchanged = (context) => {
console.log(context?.theme, context?.displayMode);
};
await app.connect();Display Modes
Widgets can run in three display modes provided by the host:
inline— Rendered within the chat message flow (default)pip— Picture-in-picture floating windowfullscreen— Full-screen overlay
The current mode is available via hostContext.displayMode. Widgets can request a mode change at runtime:
// Toggle between inline and fullscreen
const result = await app.requestDisplayMode({ mode: 'fullscreen' });
console.log(result.mode); // the mode the host actually switched toThe host decides whether to honor the request — always use the returned result.mode as the source of truth.
Container Dimensions
Hosts provide containerDimensions in the host context so widgets can size themselves responsively:
app.onhostcontextchanged = (context) => {
const { maxHeight, maxWidth } = context?.containerDimensions ?? {};
// Use maxHeight/maxWidth to constrain your layout
};This replaces viewport-based sizing and ensures widgets respect the host's available space (especially important in inline mode).
Runtime APIs
// Call other tools from the widget
const result = await app.callServerTool({
name: 'tool_name',
arguments: { arg: 'value' },
});
// Open an external link via the host
await app.openLink({ url: 'https://example.com' });
// Send a message to the host chat
await app.sendMessage({
role: 'user',
content: [{ type: 'text', text: 'Hello from the widget!' }],
});
// Push widget state to the model context for future turns
await app.updateModelContext({
content: [{ type: 'text', text: 'Current widget state summary' }],
structuredContent: { key: 'value' },
});
// Toggle display mode
await app.requestDisplayMode({ mode: 'fullscreen' });UI Capability Negotiation
The server inspects the client's capabilities on each request, carried in that request's _meta, and adapts its responses:
UI-capable hosts (ChatGPT, VS Code, etc.) — Tools include
_meta.ui.resourceUriand returnstructuredContentfor the widget to renderText-only hosts (terminal clients, basic MCP consumers) — Tools still advertise UI metadata, which hosts can ignore, and return plain text responses without
structuredContent
This happens automatically via getUiCapability() from @modelcontextprotocol/ext-apps/server. No widget changes are needed — the server handles the fallback.
How Development Serving Works
npm run dev runs three processes side by side:
Process | What it does |
| MCP server on |
| Vite dev server on |
|
|
These form two independent delivery pipelines from the same source files. The watch build is never involved in the HMR path — it exists solely to keep a self-contained snapshot on standby for hosts that need one.
First render (a host requests the widget)
When a tool call renders the widget, the host issues resources/read and the server inspects the client's identity (clientInfo in the request _meta):
Matches
WIDGET_INLINE_CLIENTS(default:claude) or sends no identity → the server returns a self-contained snapshot: the latestassets/build with JS/CSS inlined and local images as data URIs. Nothing is fetched at runtime; the iframe has no connection back to your machine.Any other identified client (e.g. ChatGPT dev mode) → the server returns a ~300-byte shell whose module script points at the Vite dev server (
BASE_URLif set, elsehttp://localhost:4444). The browser loads your source files as native ES modules, transformed in memory — theassets/build is not involved at any point. CSPresourceDomains/connectDomains(including thews(s)://HMR socket) are set to match.
While you develop (save a file)
HMR clients — Vite pushes the changed module over the websocket and React Fast Refresh swaps it in place. Instant, no reload, component state preserved. The watch build also re-runs in the background, but its output isn't used by these clients, and the dev server deliberately ignores
assets/writes so a finishing build can never trigger a page reload.Inline clients (Claude.ai) — the rendered widget is a frozen snapshot; nothing can be pushed to it. The watch build finishes (~1s) and the server re-inlines automatically, so the next
resources/readreturns fresh HTML. Invoke the tool again to see your changes — a browser refresh may serve a host-cached copy, so re-invoking is the reliable path.
Experimental:
WIDGET_BOOTSTRAP_CLIENTSserves matching clients a shell that loads the dev module graph via dynamicimport()instead of a static script tag — srcdoc-iframe hosts like Claude.ai don't execute static external script tags but may allow dynamic loading fromresourceDomainsorigins. If this proves out, Claude.ai can join the HMR pipeline too. RequiresBASE_URLset to an https tunnel; off by default.
Production is unaffected
None of this machinery runs in production (NODE_ENV=production):
npm run buildoutput is unchanged: hashed bundles inassets/plus HTML referencing them viaBASE_URLThe server never inlines, never serves dev-server HTML, and ignores
WIDGET_INLINE_CLIENTS/WIDGET_BOOTSTRAP_CLIENTS— all per-client switching is gated onNODE_ENV=developmentHosts fetch widget assets from
BASE_URL(CDN or static host) exactly as before
Inline Widget Assets
Some hosts (e.g. Claude.ai) require fully self-contained HTML — external <script> and <link> tags won't load inside their sandboxed iframes.
In development, npm run dev handles this automatically and per request: the server inspects each MCP request's client identity (clientInfo in _meta) and serves inlined HTML to clients matching WIDGET_INLINE_CLIENTS (default: claude) and to clients that don't identify themselves. Everyone else gets live dev-server modules with HMR. A background vite build --watch keeps the inlined HTML fresh on every file change — you never run a build manually.
Inlined HTML is self-contained:
JS/CSS — inlined as
<script>/<style>blocksLocal images — inlined as data URIs via Vite's
assetsInlineLimitFonts — loaded via Google Fonts (the required domains
fonts.googleapis.comandfonts.gstatic.comare automatically added toresourceDomainsin the CSP)
To force inlining for every client regardless of identity, run npm run dev:inline (sets INLINE_DEV_MODE=true). To change which clients are inlined, set WIDGET_INLINE_CLIENTS (comma-separated, case-insensitive substring match against the client's name/title). The server logs each widget request's clientInfo and the chosen mode, so it's easy to see what a host identifies as.
Inlining is not used in production — once widget assets are deployed to a public URL (
BASE_URL), hosts fetch them directly via normal URLs.
Loading External Resources (Images, APIs, etc.)
MCP Apps hosts render widgets inside sandboxed iframes with a strict Content Security Policy (CSP). By default, remote images and other external resources will be blocked — even if the HTTP request succeeds (returns 200), the browser won't render the response inside the iframe.
To allow external domains, declare them in the resource's _meta.ui.csp.resourceDomains:
return {
contents: [
{
uri: resourceUri,
mimeType: RESOURCE_MIME_TYPE,
text: html,
_meta: {
ui: {
csp: {
resourceDomains: [
'https://cdn.example.com',
'https://api.example.com',
],
connectDomains: ['https://api.example.com'], // for fetch/XHR
},
},
},
},
],
};The host merges these domains into the iframe's CSP, allowing the widget to load images, fonts, and other resources from the specified origins.
Key points:
Remote images require
resourceDomains— without it,<img src="https://...">will silently fail in most hostsData URIs always work — images imported via Vite (
import img from './photo.png') are inlined as data URIs whenassetsInlineLimitis set (see Inline Widget Assets)Each domain must be explicitly listed — wildcards are not supported; include all domains your widget needs (e.g. both
https://picsum.photosandhttps://fastly.picsum.photosif the first redirects to the second)connectDomains— use this forfetch()/XMLHttpRequestcalls to external APIs
Mock App for Testing & Storybook
The createMockApp() helper (widgets/src/mocks/mock-app.ts) provides a drop-in replacement for the real App instance, making it easy to test widgets and develop them in Storybook without a live MCP connection:
import { createMockApp } from '../mocks/mock-app';
const mockApp = createMockApp({
toolOutput: { echoedMessage: 'Hello', timestamp: '2025-01-01T00:00:00Z' },
hostContext: { theme: 'dark', displayMode: 'inline' },
});
// Pass to your widget
<Echo app={mockApp} />
// Simulate new tool results or context changes
mockApp.emitToolResult({ echoedMessage: 'Updated', timestamp: '...' });
mockApp.setHostContext({ theme: 'light', displayMode: 'fullscreen' });Example: Full Widget with Safe Area
// widgets/src/widgets/my-widget.tsx
import { StrictMode, useEffect, useState } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from '@modelcontextprotocol/ext-apps';
function MyWidget() {
const [toolOutput, setToolOutput] = useState(null);
const [theme, setTheme] = useState('light');
const [safeAreaInsets, setSafeAreaInsets] = useState({
top: 0,
bottom: 0,
});
useEffect(() => {
const app = new App({ name: 'MyWidget', version: '1.0.0' });
app.ontoolresult = (result) =>
setToolOutput(result.structuredContent ?? null);
app.onhostcontextchanged = (context) => {
setTheme(context?.theme ?? 'light');
setSafeAreaInsets({
top: context?.safeAreaInsets?.top ?? 0,
bottom: context?.safeAreaInsets?.bottom ?? 0,
});
};
app.connect();
}, []);
const containerStyle = {
paddingTop: safeAreaInsets.top,
paddingBottom: safeAreaInsets.bottom,
};
return (
<div style={containerStyle} className={theme === 'dark' ? 'dark' : ''}>
<h1>My Widget</h1>
<p>Tool output: {JSON.stringify(toolOutput)}</p>
</div>
);
}
// Mounting code - required at the bottom of each widget file
const rootElement = document.getElementById('my-widget-root');
if (rootElement) {
createRoot(rootElement).render(
<StrictMode>
<MyWidget />
</StrictMode>
);
}Configuration
Environment Variables
Create .env file (see .env.example):
# Server
NODE_ENV=development
PORT=8080
LOG_LEVEL=info # fatal, error, warn, info, debug, trace
# CORS (development)
CORS_ORIGIN=*
# Public base URL for widget assets
# Dev: a tunnel to the widget dev server (enables HMR through hosts that load external assets)
# Production: your CDN/static host (required)
# BASE_URL=https://cdn.example.com/assets
# Clients that get fully inlined widget HTML in dev (comma-separated substring
# match on client name/title; unidentified clients are always inlined)
# WIDGET_INLINE_CLIENTS=claude
# Force inlined widget HTML for every client (npm run dev:inline sets this)
# INLINE_DEV_MODE=trueCritical Configuration Notes
text/html;profile=mcp-app MIME Type
Required for MCP Apps hosts to load UI:
return {
contents: [
{
uri: 'ui://my-widget',
mimeType: 'text/html;profile=mcp-app', // ← CRITICAL
text: html,
},
],
};Bundle Size Limits
Widget bundles: Warn at 500kb (configured in Vite)
Widget state: Keep under 4,000 tokens for performance
API Reference
MCP Server Endpoints
Endpoint | Method | Description |
| GET | Health check |
| POST | Stateless MCP request endpoint |
Echo Tool Schema
{
"name": "echo",
"description": "Echoes back the user's message in an interactive widget",
"inputSchema": {
"type": "object",
"properties": {
"message": {
"type": "string",
"description": "The message to echo back"
}
},
"required": ["message"]
}
}Tool Response Format
{
content: [{ type: 'text', text: 'Human-readable message' }],
structuredContent: {
// JSON data passed to the app via App.ontoolresult
echoedMessage: 'Hello',
timestamp: '2025-01-...'
},
// UI binding is defined in tool _meta.ui.resourceUri
}Testing & Quality Assurance
Running Tests
# Run all tests (server + widgets)
npm test
# Run specific workspace tests
npm run test:server
npm run test:widgets
# Run with coverage report
npm run test:coverageTest Structure
Server Tests (server/tests/):
Input validation with Zod
Tool response structure
Stateless transport behavior
Error handling
Widget Tests (widgets/tests/):
Component rendering
User interactions
Accessibility (a11y) compliance
MCP Apps App API mocking
MCP Inspector Workflow
# 1. Start server
npm run dev:server
# 2. Build widgets
npm run build:widgets
# 3. Test with Inspector
npm run inspect
# 4. Verify:
# - Tools list correctly
# - Tool invocations work
# - Widget HTML loads
# - structuredContent is correctProduction Deployment
Building for Production
The production build process compiles widgets with optimizations and prepares the server:
# Full production build
npm run buildThis runs:
npm run build:widgets- Builds optimized widget bundles with content hashingnpm run build:server- Compiles TypeScript server code
Build outputs:
assets/- Optimized widget bundles (JS/CSS with content hashes)server/dist/- Compiled server code
Manual Deployment
# 1. Install dependencies
npm install
# 2. Build for production
npm run build
# 3. Start production server
NODE_ENV=production npm startThe server will:
Serve MCP on
http://localhost:8080/mcpLoad pre-built widgets from
assets/Use structured logging (JSON format)
Run with production optimizations
Docker Deployment
# Build image
docker build -f docker/Dockerfile -t mcp-app:latest .
# Run with docker-compose
docker-compose -f docker/docker-compose.yml up -d
# Check logs
docker-compose -f docker/docker-compose.yml logs -f
# Health check
curl http://localhost:8080/healthProduction Checklist
Environment Variables:
Set
NODE_ENV=productionConfigure
CORS_ORIGINto your domain (not*)Set
LOG_LEVEL=warnorerrorfor productionSet
BASE_URLif using a CDN for widget assets
Deployment Requirements:
MCP Server: Must be behind a Pomerium route, which handles OAuth authentication and lets you set policies to control who can access the server and which tools they can use
Widget assets: Must be served from a publicly accessible URL — either from the same server, a CDN (
BASE_URL), or a static host like Netlify/VercelEnsure
assets/directory is deployed with the server (or served separately viaBASE_URL)Set up SSL/TLS certificates (most MCP hosts require HTTPS)
Monitoring:
Monitor
/healthendpoint for server statusSet up logging aggregation (Pino outputs JSON in production)
Configure alerts for errors and performance issues
Troubleshooting
Widget Not Loading
Symptom: Widget doesn't appear in a host
Solutions:
Verify
text/html;profile=mcp-appMIME type in resource registrationCheck assets directory exists:
ls assets/Rebuild widgets:
npm run build:widgetsRestart server and refresh connector in the host
Tool Not Listed
Symptom: Tool doesn't appear in a host
Solutions:
Check server logs for errors
Test with MCP Inspector:
npm run inspectRefresh connector: Settings → Connectors → Refresh
Verify tool schema is valid JSON Schema
Stateless HTTP Issues
This release uses the MCP 2026-07-28 stateless transport. Older 2025-era clients are supported through the SDK's stateless legacy fallback, but the fallback does not preserve transport sessions, session IDs, standalone SSE streams, or resumability. Clients that require those stateful behaviors should use a pre-2.0.0 release.
Build Failures
Symptom: npm run build:widgets fails
Solutions:
Clear node_modules:
rm -rf node_modules && npm installCheck for TypeScript errors:
npm run type-checkVerify all dependencies installed
Check Node.js version:
node -v(should be 24+)
Port Already in Use
Symptom: Error: listen EADDRINUSE: address already in use :::8080
Solutions:
Change port in
.env:PORT=3001Kill existing process:
lsof -ti:8080 | xargs kill
Architecture Decisions
Why McpServer + MCP Apps Helpers?
The template uses McpServer from @modelcontextprotocol/server together with @modelcontextprotocol/ext-apps/server helpers because:
registerAppToolandregisterAppResourcehandle MCP Apps metadata wiring consistentlyTool UI binding is declared with
_meta.ui.resourceUriin one placeThe pattern is portable across MCP Apps hosts (ChatGPT, VS Code, Claude, Goose)
The HTTP endpoint uses createMcpHandler from the v2 TypeScript SDK. The handler creates a fresh server and transport per request, which implements the 2026-07-28 stateless protocol and allows horizontal scaling without session affinity.
Why Node.js 24 + ES2023?
Native type stripping support
Immutable array methods (
.toSorted(),.toReversed())Better performance and modern JavaScript features
Why Tailwind CSS v4?
Modern, performant, and well-documented
Great dark mode support out of the box
Smaller bundle sizes with new engine
Why Pino for Logging?
Fast, structured logging for production
Pretty printing in development
Easy integration with monitoring tools
Contributing
Contributions welcome! Please:
Follow existing code style (ESLint + Prettier)
Add tests for new features
Update documentation
Ensure TypeScript strict mode compliance
License
MIT
Built with:
This server cannot be installed
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Related MCP Connectors
Hosted MCP endpoint with realistic fake data for prototyping agents. 12 tools, no setup.
Automate 1,000+ services from any MCP-compatible AI agent: build Applets, run actions and queries.
Hosted MCP for creating, checking, deploying, and hosting static sites for AI agents.
MCP server for secureFlows: token-free URL builders and integration-linting tools for AI agents.
Related MCP Servers
- FlicenseNot gradedqualityDmaintenanceA production-ready template for building MCP servers on Cloudflare Workers that expose server-side tools with rich, interactive React-based UI widgets using the MCP Extensions Apps API.13
- FlicenseNot gradedqualityDmaintenanceAn MCP server template for building ChatGPT-compatible React widgets using the OpenAI Apps SDK. It automatically registers UI components as MCP tools and resources, featuring built-in support for theme detection and ecommerce-focused interactive elements.
- FlicenseNot gradedqualityCmaintenanceA starter TypeScript template for building MCP and ChatGPT apps with the Skybridge framework, featuring tool registration, React views, and deployment to MCP-compatible clouds.
- AlicenseNot gradedqualityCmaintenanceAn MCP App template for building interactive UIs on deco, providing tools, resources, and a React frontend.11ISC
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/pomerium/mcp-app-typescript-template'
If you have feedback or need assistance with the MCP directory API, please join our Discord server