MCP Apps Template Server
by pomerium
README.md
# MCP Apps Template
A well-architected starter template demonstrating best practices for building MCP Apps using the [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) with [React](https://react.dev/) widgets. It leverages TypeScript, Tailwind CSS v4, Pino logging, Storybook, and Vitest for a robust development experience.
## Features
- **MCP Server** - Node.js server with `McpServer` and MCP Apps helpers
- **Echo Tool** - Example tool with [Zod](https://zod.dev/) validation and UI binding
- **React Widgets** - Interactive Echo component with MCP Apps `App` API demo
- **Display Modes** - Inline, picture-in-picture, and fullscreen with runtime toggling via `requestDisplayMode()`
- **App API Demo** - `callServerTool`, `openLink`, `sendMessage`, `updateModelContext` showcased in the Echo widget
- **Stateless MCP HTTP** - Per-request server factories with no transport sessions or session affinity
- **No-Build Dev Loop** - Widgets load live from the Vite dev server with HMR in Claude.ai and ChatGPT
- **Container Dimensions** - Responsive widget sizing using host-provided `containerDimensions`
- **Mock App** - Drop-in `createMockApp()` helper for testing and Storybook without a live MCP connection
- **[Pino](https://getpino.io/) Logging** - Structured logging with pretty printing in development
- **TypeScript** - Strict mode with ES2023 target
- **[Tailwind CSS v4](https://tailwindcss.com/)** - Modern styling with dark mode support
- **[Storybook](https://storybook.js.org/)** - Component development with a11y addon
- **Testing** - [Vitest](https://vitest.dev/) for server and widgets with accessibility checks
- **Build Optimizations** - Parallel builds, content hashing, compression
- **[Docker](https://www.docker.com/)** - Multi-stage builds with health checks
- **Production Ready** - Stateless transport, graceful shutdown, and error handling
## Architecture
```mermaid
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:#f3e5f5
```
## Quick Start
**Setup time: ~5 minutes (first time)**
### Prerequisites
- **[Node.js](https://nodejs.org/) 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
```bash
git clone https://github.com/pomerium/mcp-app-typescript-template your-mcp-app
cd your-mcp-app
npm install
npm run dev
```
This starts everything you need — no manual build step:
- **MCP Server**: `http://localhost:8080`
- **Widget dev server** (live modules + HMR): `http://localhost:4444`
There is no build step in development. Every host gets a small HTML shell that loads your widget straight from the Vite dev server, so edits show up via hot module replacement, in Claude.ai and ChatGPT alike. Hosted clients load the widget from inside an https sandbox, so they need `BASE_URL` set to a public https tunnel of port 4444 (see [How Development Serving Works](#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 inspect` for 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 "npm run dev:server" "npm run dev:widgets"
[widgets] > vite
[server] > tsx watch src/server.ts
[widgets] Found 1 widget(s):
[widgets] - echo
[widgets]
[widgets] VITE v8.2.1 ready in 310 ms
[widgets] ➜ Local: http://localhost:4444/
[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](https://www.pomerium.com/docs/tcp/ssh):
**1. Create a public tunnel** (in a new terminal, keep `npm run dev` running):
```bash
ssh -R 0 pom.run
```
**First-time setup:**
1. 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
```
2. Click the link and sign up
3. Authorize via the Pomerium OAuth flow
4. 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:**
1. Enable MCP apps dev mode in your ChatGPT settings
2. Go to: **Settings → Connectors → Add Connector**
3. Enter your Remote URL + `/mcp`, e.g. `https://template.first-wallaby-240.pom.run/mcp`
4. Save the connector
**4. Test it:**
1. Start a new chat in ChatGPT
2. Add your app to the chat
3. Send: `echo today is a great day`
4. You 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 (Settings → Connectors → Add custom connector). Claude.ai, like ChatGPT, loads the widget from an https sandbox, so also set up the widget dev server tunnel described next to get live modules and HMR.
**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.
### Tunnel the Widget Dev Server (HMR in hosted clients)
Hosted clients (Claude.ai, ChatGPT dev mode) build the widget sandbox's CSP from the origins the server declares, so they can load live widget modules, with hot module replacement, through a second tunnel pointed at the widget dev server:
```bash
# Second terminal: tunnel the widget dev server (port 4444)
ssh -R 0:localhost:4444 pom.run
# or, without a Pomerium account:
cloudflared tunnel --url http://localhost:4444
```
Then set `BASE_URL` in `.env` to that tunnel's public URL and restart `npm run dev`:
```bash
BASE_URL=https://widgets.first-wallaby-240.pom.run
```
Widget 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 and is not itself served over https (the sandbox CSP upgrades insecure requests). If you can't run a tunnel, `npm run build` and point `BASE_URL` at any static host that serves `assets/`.
#### The widget tunnel must pass websockets
Vite's HMR client connects back over a websocket (`wss://<widget-tunnel>/?token=...`). If the tunnel or proxy in front of port 4444 drops websocket upgrades, modules still load and the widget still renders, but edits stop showing up until you re-invoke the tool. There's no error in the chat, so check the widget page's console for `[vite] connected.` if hot reload seems dead.
cloudflared quick tunnels and `pom.run` pass websockets by default. If you run your own Pomerium and tunnel through it with `ssh -R`, the widget route needs `allow_websockets: true`. Two routes cover the whole dev setup, one for the MCP server and one for the widget dev server:
```yaml
routes:
# MCP server (port 8080). Behind Pomerium's MCP OAuth flow; hosts such as
# claude.ai and ChatGPT authenticate through it.
- from: https://mcp-dev.example.com
to: http://localhost:8080
mcp:
server: {}
policy:
- allow:
and:
- email:
in:
- you@example.com
upstream_tunnel:
ssh_policy:
# Who can open the reverse tunnel that backs this route
- allow:
and:
- email:
in:
- you@example.com
# Widget dev server (port 4444). No `mcp:` block: this is fetched by the
# host's sandbox iframe and by ChatGPT's server-side fetcher, neither of
# which carries a Pomerium session, so it has to be publicly readable.
# upstream_tunnel.ssh_policy still restricts who can open the tunnel.
- from: https://widgets-dev.example.com
to: http://localhost:4444
allow_public_unauthenticated_access: true
# Required for Vite HMR in development.
allow_websockets: true
upstream_tunnel:
ssh_policy:
- allow:
and:
- email:
in:
- you@example.com
```
Then `ssh -R 0:localhost:8080 <your-pomerium-ssh-host>` and `ssh -R 0:localhost:4444 <your-pomerium-ssh-host>` back the two routes, and `BASE_URL=https://widgets-dev.example.com`.
### Success! What's Next?
Now that your app is working, you can:
- **[Customize the echo tool](#adding-new-tools)** - Modify the example tool or add your own logic
- **[Create a new widget](#widget-development)** - Build custom UI components for your tools
- **[Test locally](#local-testing-with-mcp-inspector)** - Use `npm run inspect` for debugging without a host
- **[Deploy to production](#production-deployment)** - Take your app live when ready
## Available Commands
### Development
```bash
# Start everything (MCP server + widget dev server). No build step; HMR in every host.
npm run dev
# 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
```
### Building
```bash
# Full production build (widgets + server)
npm run build
# Build only widgets
npm run build:widgets
# Build only server
npm run build:server
```
### Testing
```bash
# 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
```
### End-to-End Testing
```bash
# Build (widgets + server) then run the Playwright suite against the production build
npm run test:e2e
```
`npm run test:e2e` covers what the Vitest suites can't: real HTTP requests
against the built server (`server/dist/server.js`, `NODE_ENV=production`) and
the real Echo widget rendered by ext-apps 2's `App`, in a real sandboxed
iframe, driven by a small test host built on `AppBridge` (from
`@modelcontextprotocol/ext-apps/app-bridge`) instead of a mocked `App`. It
runs on Chromium only, against non-default ports (8390/4390/5390) so it never
collides with a local `npm run dev`, and needs Chromium installed once via
`npx playwright install --with-deps chromium`.
- `e2e/transport.spec.ts` — plain HTTP: 2026-07-28 `tools/list`/`tools/call`
with no handshake, `structuredContent` gated on the MCP Apps capability,
`resources/read ui://echo` CSP metadata, and the 2025-era `initialize`
fallback.
- `e2e/widget.spec.ts` — `e2e/host/` is a minimal host page that connects a
real MCP client, loads the `ui://echo` resource into a sandboxed iframe,
and wires up `AppBridge` the way a real host (MCPJam, Claude.ai, ChatGPT)
would. The spec drives every widget action — `callServerTool`,
`updateModelContext`, `sendMessage`, `requestDisplayMode`, `openLink`,
theme — and asserts the widget's own iframe logs no console errors.
The Vitest suites (`npm test`) are unrelated and untouched by this suite.
### Code Quality
```bash
# 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
```
### Storybook
```bash
# Run Storybook dev server
npm run storybook
# Build Storybook for production
npm run build:storybook
```
### Testing Your App
#### 1. Local Testing with MCP Inspector
```bash
npm run inspect
```
This opens MCPJam's browser interface. Add your server manually:
1. Open the **Servers** tab in the MCPJam sidebar
2. Select **HTTP** as the transport type
3. Enter the server URL: `http://localhost:8080/mcp`
See MCPJam's [Connecting Servers](https://docs.mcpjam.com/inspector/connecting-servers) docs for headers, auth, and timeout options.
Once connected, use MCPJam 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](#connect-to-a-host-chatgpt-example) section above.
**Already connected?** After making code changes:
1. **Settings → Connectors → Your App → Refresh**
2. This reloads tool definitions and metadata
**Production Setup:**
When deploying to production:
1. Deploy your server to a public URL (see [Production Deployment](#production-deployment))
2. In ChatGPT: **Settings → Connectors → Add Connector**
3. Enter your server URL: `https://your-domain.com/mcp`
4. Test the `echo` tool 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 workspace
```
## Adding New Tools
### 1. Define Tool Schema
```typescript
// server/src/types.ts
import { z } from 'zod';
export const MyToolInputSchema = z.object({
input: z.string().min(1, 'Input is required'),
});
```
### 2. Register Tool (with UI)
```typescript
registerAppTool(
server,
'my_tool',
{
title: 'My Tool',
description: 'Does something cool',
inputSchema: MyToolInputSchema,
_meta: {
ui: { resourceUri: 'ui://my-widget' },
},
},
async (args) => {
// args is already typed and validated against `inputSchema` (a Zod
// object) before this callback runs.
return {
content: [{ type: 'text', text: 'Result' }],
structuredContent: { result: args.input },
};
}
);
```
### 3. Create Widget
Create `widgets/src/widgets/my-widget.tsx`:
```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
```typescript
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
```bash
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
## Widget Development
### Widget Pattern
Widgets include both the component and mounting code:
**1. Create widget entry point** in `widgets/src/widgets/[name].tsx`:
```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**:
```bash
npm run build:widgets
```
**3. 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
```typescript
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 window
- **`fullscreen`** — Full-screen overlay
The current mode is available via `hostContext.displayMode`. Widgets can request a mode change at runtime:
```typescript
// 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.
#### Container Dimensions
Hosts provide `containerDimensions` in the host context so widgets can size themselves responsively:
```typescript
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
```typescript
// 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.resourceUri` and return `structuredContent` for the widget to render
- **Text-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 two processes side by side:
| Process | What it does |
| ------------- | ------------------------------------------------------------------------- |
| `dev:server` | MCP server on `:8080` — serves the widget HTML shell and its CSP metadata |
| `dev:widgets` | Vite dev server on `:4444` — live source modules + HMR websocket |
Nothing is built in development. `assets/` is only produced by `npm run build` for production.
#### First render (a host requests the widget)
When a tool call renders the widget, the host issues `resources/read` and the server returns a **~300-byte shell** whose module script points at the Vite dev server: `BASE_URL` if set, else `http://localhost:4444`. The resource's `_meta.ui.csp` declares that origin in `resourceDomains` and `connectDomains` (plus its `ws(s)://` form for the HMR socket). The host builds the sandbox CSP from those declarations, so the browser loads your source files as native ES modules transformed in memory, and Vite's client opens its HMR websocket back to the dev server.
Hosted clients (Claude.ai, ChatGPT) render the widget from an https sandbox origin, which cannot reach `http://localhost`. For those, `BASE_URL` must be a public https tunnel to port 4444 (a `cloudflared tunnel --url http://localhost:4444` quick tunnel or an `ssh -R 0:localhost:4444 pom.run` session). The Vite config derives `allowedHosts` and the HMR websocket settings from `BASE_URL`, and the server logs a warning when a hosted client asks for the widget while `BASE_URL` is unset. Local hosts (MCP inspectors, desktop apps that render MCP Apps) can use localhost directly.
#### While you develop (save a file)
Vite pushes the changed module over the websocket and React Fast Refresh swaps it in place: no reload, component state preserved, no tool re-invocation. This is the same in every host that loaded the shell.
#### Production is unaffected
None of this machinery runs in production (`NODE_ENV=production`):
- `npm run build` output is unchanged: hashed bundles in `assets/` plus HTML referencing them via `BASE_URL`
- The server never points at a dev server; hosts fetch widget assets from `BASE_URL` (CDN or static host) exactly as before
### 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`:
```typescript
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 hosts
- **Data URIs always work** — small images imported via Vite (`import img from './photo.png'`) become data URIs (Vite's default `assetsInlineLimit` is 4 KiB; raise it in `widgets/vite.config.ts` to inline more); larger ones are served from the widget origin, which is already in `resourceDomains`
- **Each domain must be explicitly listed** — wildcards are not supported; include all domains your widget needs (e.g. both `https://picsum.photos` and `https://fastly.picsum.photos` if the first redirects to the second)
- **`connectDomains`** — use this for `fetch()`/`XMLHttpRequest` calls 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:
```typescript
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
```tsx
// 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`):
```bash
# 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: an https tunnel to the widget dev server (needed for hosted clients such as Claude.ai and ChatGPT)
# Production: your CDN/static host (required)
# BASE_URL=https://cdn.example.com/assets
```
### Critical Configuration Notes
#### text/html;profile=mcp-app MIME Type
**Required** for MCP Apps hosts to load UI:
```typescript
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 |
| --------- | ------ | ------------------------------ |
| `/health` | GET | Health check |
| `/mcp` | POST | Stateless MCP request endpoint |
### Echo Tool Schema
```json
{
"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
```typescript
{
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
```bash
# 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
```
### Test Structure
**Server Tests** (`server/tests/`):
- Input validation with Zod
- Tool response structure
- Stateless transport behavior
- Error handling
- End-to-end requests through the real `createHandler()` (modern 2026-07-28 and legacy 2025-era fallback) — see `server/tests/server.test.ts`
**Widget Tests** (`widgets/tests/`):
- Component rendering
- User interactions
- Accessibility (a11y) compliance
- MCP Apps App API mocking
### MCP Inspector Workflow
```bash
# 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
```
## Production Deployment
### Building for Production
The production build process compiles widgets with optimizations and prepares the server:
```bash
# Full production build
npm run build
```
This runs:
1. `npm run build:widgets` - Builds optimized widget bundles with content hashing
2. `npm 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
```bash
# 1. Install dependencies
npm install
# 2. Build for production
npm run build
# 3. Start production server
NODE_ENV=production npm start
```
The server will:
- Serve MCP on `http://localhost:8080/mcp`
- Load pre-built widgets from `assets/`
- Use structured logging (JSON format)
- Run with production optimizations
### Docker Deployment
```bash
# 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/health
```
### Production Checklist
**Environment Variables:**
- Set `NODE_ENV=production`
- Configure `CORS_ORIGIN` to your domain (not `*`)
- Set `LOG_LEVEL=warn` or `error` for production
- Set `BASE_URL` if using a CDN for widget assets
**Deployment Requirements:**
- **MCP Server:** Must be behind a [Pomerium](https://www.pomerium.com/) 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/Vercel
- Ensure `assets/` directory is deployed with the server (or served separately via `BASE_URL`)
- Set up SSL/TLS certificates (most MCP hosts require HTTPS)
**Monitoring:**
- Monitor `/health` endpoint for server status
- Set 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**:
1. Verify `text/html;profile=mcp-app` MIME type in resource registration
2. Check assets directory exists: `ls assets/`
3. Rebuild widgets: `npm run build:widgets`
4. Restart server and refresh connector in the host
### Tool Not Listed
**Symptom**: Tool doesn't appear in a host
**Solutions**:
1. Check server logs for errors
2. Test with MCP Inspector: `npm run inspect`
3. Refresh connector: Settings → Connectors → Refresh
4. 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.
#### Upgrading from ext-apps 1.x / `@modelcontextprotocol/sdk`
If you're porting a fork or older code sample onto this template's `@modelcontextprotocol/ext-apps` 2.x baseline:
- Remove `@modelcontextprotocol/sdk` (v1) entirely — it's not a dependency anywhere in this template; ext-apps 2.x sits on the split v2 packages instead
- Import wire types (e.g. `TextContent`, `CallToolResult`) from `@modelcontextprotocol/client` in widget code, or from `@modelcontextprotocol/server` in server code — not from `@modelcontextprotocol/sdk/types.js`
- Pass a Zod object schema directly as `inputSchema` (e.g. `inputSchema: MyToolInputSchema`), not the deprecated raw shape (`MyToolInputSchema.shape`); Zod must be `^4.2.0` or newer
- Tool and resource callbacks receive a v2 `ServerContext` as `ctx` directly (`ctx.mcpReq.id`, `ctx.mcpReq.signal`) — there's no `sessionId` and no need to cast `ctx`/`extra` with `as unknown as ServerContext`
See the upstream [migrate-to-v2 guide](https://apps.extensions.modelcontextprotocol.io/api/documents/migrate-to-v2.html) for the full list of breaking changes.
### Build Failures
**Symptom**: `npm run build:widgets` fails
**Solutions**:
1. Clear node_modules: `rm -rf node_modules && npm install`
2. Check for TypeScript errors: `npm run type-check`
3. Verify all dependencies installed
4. Check Node.js version: `node -v` (should be 24+)
### Port Already in Use
**Symptom**: `Error: listen EADDRINUSE: address already in use :::8080`
**Solutions**:
1. Change port in `.env`: `PORT=3001`
2. Kill 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:
- `registerAppTool` and `registerAppResource` handle MCP Apps metadata wiring consistently
- Tool UI binding is declared with `_meta.ui.resourceUri` in one place
- The 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.
`@modelcontextprotocol/ext-apps` is on the 2.x line, which is built on the split v2 SDK packages (`@modelcontextprotocol/server`, `client`, `core`, `node`) rather than one monolithic package. The old v1 `@modelcontextprotocol/sdk` package is not a dependency anywhere in this template.
### 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
### Why Two TypeScript Compilers?
TypeScript 7's native (Go-ported) compiler doesn't expose the legacy programmatic API that `typescript-eslint` still depends on. Until `typescript-eslint` ships native TS7 support ([tracking issue](https://github.com/typescript-eslint/typescript-eslint/issues/10940)), this template uses npm aliases to run both side by side:
- `typescript` is aliased to `@typescript/typescript6`, the TS6-compatible compiler API package — this is what `typescript-eslint` resolves and lints against.
- `@typescript/native` is aliased to the real `typescript@^7`, which provides the `tsc` binary used by `npm run build` and `npm run type-check`.
If you ever need to invoke the TS6 compiler directly, its binary is available as `tsc6`. No other configuration should be needed; if `npm run lint` or `npm run type-check` misbehaves after a dependency update, delete `package-lock.json` and reinstall so npm fully re-resolves both aliases (an incremental `npm install` after editing these two lines directly can leave a stale bin symlink).
## Contributing
Contributions welcome! Please:
1. Follow existing code style (ESLint + Prettier)
2. Add tests for new features
3. Update documentation
4. Ensure TypeScript strict mode compliance
## License
MIT
---
**Built with**:
- [MCP Apps Spec](https://modelcontextprotocol.github.io/ext-apps/api/)
- [Model Context Protocol](https://modelcontextprotocol.io/)
- [React 19](https://react.dev/)
- [Tailwind CSS v4](https://tailwindcss.com/)
- [Vite](https://vitejs.dev/)
- [Pino](https://getpino.io/)
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessSlow