loading…
Search for a command to run...
loading…
A starter template for building MCP apps with React widgets, featuring tool execution, UI capability negotiation, and host integration.
A starter template for building MCP apps with React widgets, featuring tool execution, UI capability negotiation, and host integration.
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.
McpServer and MCP Apps helpersApp API demorequestDisplayMode()callServerTool, openLink, sendMessage, updateModelContext showcased in the Echo widgetcontainerDimensionscreateMockApp() helper for testing and Storybook without a live MCP connectiongraph 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:#f3e5f5
Setup time: ~5 minutes (first time)
node -v (should show v24.0.0 or higher)npm -v (should show v10.0.0 or higher)Supported platforms: macOS, Linux, Windows (via WSL2)
git clone https://github.com/pomerium/chatgpt-app-typescript-template your-chatgpt-app
cd your-chatgpt-app
npm install
npm run dev
This starts both the MCP server and widget dev server:
http://localhost:8080http://localhost:4444Note: 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
> [email protected] dev
> concurrently "npm run dev:server" "npm run dev:widgets"
[1]
[1] > [email protected] dev:widgets
[1] > npm run dev --workspace=widgets
[1]
[0]
[0] > [email protected] dev:server
[0] > npm run dev --workspace=server
[0]
[1]
[1] > [email protected] dev
[1] > vite
[1]
[0]
[0] > [email protected] dev
[0] > tsx watch src/server.ts
[0]
[1]
[1] Found 1 widget(s):
[1] - echo
[1]
[1]
[1] VITE v6.4.1 ready in 151 ms
[1]
[1] ➜ Local: http://localhost:4444/
[1] ➜ Network: use --host to expose
[0] [12:45:12] INFO: Starting MCP App Template server
[0] port: 8080
[0] nodeEnv: "development"
[0] logLevel: "info"
[0] assetsDir: "/Users/nicktaylor/dev/oss/chatgpt-app-typescript-template/assets"
[0] [12:45:12] INFO: Server started successfully
[0] port: 8080
[0] mcpEndpoint: "http://localhost:8080/mcp"
[0] healthEndpoint: "http://localhost:8080/health"
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.run
First-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-code
Click 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:
ACTIVE (tunnel is running)https://template.first-wallaby-240.pom.run (your unique URL)http://localhost:8080 (your local server)3. Add to ChatGPT:
/mcp, e.g. https://template.first-wallaby-240.pom.run/mcp4. Test it:
echo today is a great day
The tunnel stays active as long as the SSH session is running.
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.
Now that your app is working, you can:
npm run inspect for debugging without a host# Start everything (server + widgets in watch mode)
npm run dev
# Inlined assets mode for testing in Claude.ai or sharing remotely via ssh -R 0 pom.run
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 inspect
# Full production build (widgets + server)
npm run build
# Build only widgets
npm run build:widgets
# Build only server
npm run build:server
# 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:coverage
# 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-check
# Run Storybook dev server
npm run storybook
# Build Storybook for production
npm run build:storybook
npm run inspect
This opens a browser interface to:
For complete ChatGPT connection instructions, see the Quick Start: Connect to a Host section above.
Already connected? After making code changes:
Production Setup:
When deploying to production:
https://your-domain.com/mcpecho tool in ChatGPTchatgpt-app-template/
├── server/ # MCP server
│ ├── src/
│ │ ├── server.ts # Main server with echo tool
│ │ ├── types.ts # Type definitions
│ │ └── utils/
│ │ └── session.ts # Session management
│ ├── 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 workspace
// server/src/types.ts
export const MyToolInputSchema = z.object({
input: z.string().min(1, 'Input is required'),
});
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 },
};
}
);
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>
);
}
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'),
},
],
})
);
npm run build:widgets
npm run dev:server
The build script auto-discovers widgets in widgets/src/widgets/*.{tsx,jsx} and bundles them with their mounting code
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:widgets
3. Widget available as ui://my-widget
The build system:
widgets/src/widgets/*.{tsx,jsx}App API Referenceconst 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();
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 overlayThe 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 to
The host decides whether to honor the request — always use the returned result.mode as the source of truth.
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).
// 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' });
The server inspects the client's capabilities during session initialization and adapts its responses:
_meta.ui.resourceUri and return structuredContent for the widget to renderThis happens automatically via getUiCapability() from @modelcontextprotocol/ext-apps/server. No widget changes are needed — the server handles the fallback.
Some hosts (e.g. Claude.ai) require fully self-contained HTML — external <script> and <link> tags won't load inside their sandboxed iframes. Inline mode is also useful when sharing your work remotely via ssh -R 0 pom.run.
npm run dev:inline
This produces self-contained HTML by:
<script>/<style> blocksassetsInlineLimitfonts.googleapis.com and fonts.gstatic.com are automatically added to resourceDomains in the CSP)The widget build runs in watch mode so file changes are automatically rebuilt.
When is inline mode needed? Only when using
ssh -R 0 pom.runto tunnel your local server. If you self-host tunneling, you can create a public route in Pomerium for widgets or host them elsewhere (Vercel, Netlify, etc.) — just add those domains toresourceDomainsin the CSP metadata.Inline mode is not needed in production — once deployed to a public URL, hosts fetch widget assets directly via normal URLs.
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:
resourceDomains — without it, <img src="https://..."> will silently fail in most hostsimport img from './photo.png') are inlined as data URIs when assetsInlineLimit is set (see Inline Widget Assets)https://picsum.photos and https://fastly.picsum.photos if the first redirects to the second)connectDomains — use this for fetch()/XMLHttpRequest calls to external APIsThe 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' });
// 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>
);
}
Create .env file (see .env.example):
# Server
NODE_ENV=development
PORT=8080
LOG_LEVEL=info # fatal, error, warn, info, debug, trace
# Session Management
SESSION_MAX_AGE=3600000 # 1 hour in milliseconds
# CORS (development)
CORS_ORIGIN=*
# Asset Base URL (for CDN)
# BASE_URL=https://cdn.example.com/assets
# Local dev only: inline JS/CSS + images, fonts via Google Fonts (npm run dev:inline)
# INLINE_DEV_MODE=true
Required for MCP Apps hosts to load UI:
return {
contents: [
{
uri: 'ui://my-widget',
mimeType: 'text/html;profile=mcp-app', // ← CRITICAL
text: html,
},
],
};
| Endpoint | Method | Description |
|---|---|---|
/health |
GET | Health check (returns status, version, session count) |
/mcp |
GET | SSE connection endpoint for MCP clients |
/mcp/messages?sessionId=<id> |
POST | Message handling for MCP protocol |
{
"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"]
}
}
{
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
}
# 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:coverage
Server Tests (server/tests/):
Widget Tests (widgets/tests/):
# 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 correct
The production build process compiles widgets with optimizations and prepares the server:
# Full production build
npm run build
This runs:
npm run build:widgets - Builds optimized widget bundles with content hashingnpm run build:server - Compiles TypeScript server codeBuild outputs:
assets/ - Optimized widget bundles (JS/CSS with content hashes)server/dist/ - Compiled server code# 1. Install dependencies
npm install
# 2. Build for production
npm run build
# 3. Start production server
NODE_ENV=production npm start
The server will:
http://localhost:8080/mcpassets/# Build image
docker build -f docker/Dockerfile -t chatgpt-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/health
Environment Variables:
NODE_ENV=productionCORS_ORIGIN to your domain (not *)LOG_LEVEL=warn or error for productionSESSION_MAX_AGE based on your use caseBASE_URL if using a CDN for widget assetsDeployment Requirements:
BASE_URL), or a static host like Netlify/Vercelassets/ directory is deployed with the server (or served separately via BASE_URL)Monitoring:
/health endpoint for server statusSymptom: Widget doesn't appear in a host
Solutions:
text/html;profile=mcp-app MIME type in resource registrationls assets/npm run build:widgetsSymptom: Tool doesn't appear in a host
Solutions:
npm run inspectSymptom: "Session not found" errors
Solutions:
SESSION_MAX_AGE settingSymptom: npm run build:widgets fails
Solutions:
rm -rf node_modules && npm installnpm run type-checknode -v (should be 24+)Symptom: Error: listen EADDRINUSE: address already in use :::8080
Solutions:
.env: PORT=3001lsof -ti:8080 | xargs killMcpServer + MCP Apps Helpers?The template uses McpServer from @modelcontextprotocol/sdk/server/mcp.js together with @modelcontextprotocol/ext-apps/server helpers because:
registerAppTool and registerAppResource handle MCP Apps metadata wiring consistently_meta.ui.resourceUri in one place.toSorted(), .toReversed())Contributions welcome! Please:
MIT
Built with:
Выполни в терминале:
claude mcp add mcp-apps-template-server -- npx Безопасность
Низкий рискАвтоматическая эвристика по публичным данным — не гарантия безопасности.