Develop an MCP App
Build interactive apps using the MCP Apps extension — the open standard for rendering interactive UI components inside MCP hosts. The Pomerium template uses the official @modelcontextprotocol/ext-apps SDK and works with any MCP Apps spec-compliant host, including ChatGPT, Claude, VS Code, and Goose.
Your MCP server handles tool execution and returns structured data that the host renders as interactive UI components in a sandboxed iframe.
What you get:
- An MCP server that registers tools and returns widget-ready structured data
- React-based widgets rendered inside your MCP host as interactive iframes
- UI capability negotiation — the server detects host capabilities and falls back to text-only for non-UI clients
- Secure authentication via Pomerium — use
pom.runfor local development or deploy permanently
Architecture
The template builds and serves two separate properties:
| Property | Dev | Production | Access policy |
|---|---|---|---|
MCP server (/mcp) | Public URL via pom.run tunnel | Public URL via Pomerium | Auth-gated — tool calls always require a Bearer token |
| Widget assets | localhost:4444 (rendered in your browser) | Public URL via Pomerium | Public — the MCP host renders widgets in a sandboxed iframe and cannot forward credentials |
The MCP server always needs a publicly reachable URL so the MCP host can reach it. Widget assets only need a public URL in production; during development your local browser loads them directly from localhost.
Tool calls always carry a Bearer token and should be gated by a strict Pomerium policy. Widgets must be publicly accessible because the MCP host renders them in a sandboxed iframe and cannot forward authentication tokens.
How it works
Your MCP server registers tools using registerAppTool from @modelcontextprotocol/ext-apps/server. Each tool response includes:
- Text content — human-readable text for the MCP host's conversation
- Structured JSON data — passed to the widget via the
App.ontoolresultcallback - Widget metadata — a
_meta.ui.resourceUripointing to a widget resource (e.g.,ui://echo)
The host renders your widget in an iframe. The widget uses the App class from @modelcontextprotocol/ext-apps to receive tool output and call back into the MCP server via app.callServerTool().
Prerequisites
- Node.js 22+ — verify with
node -v - npm 10+ — ships with Node 22, verify with
npm -v - An MCP Apps-compatible host (e.g., ChatGPT, Claude, VS Code, Goose)
Step-by-step
1. Scaffold from the template
git clone https://github.com/pomerium/chatgpt-app-typescript-template my-mcp-app
cd my-mcp-app
npm install
npm run dev
This starts both the MCP server (http://localhost:8080) and widget dev server (http://localhost:4444).
2. Expose your MCP server with pom.run
The MCP host needs a public URL to reach your server — localhost won't work. In a new terminal (keep npm run dev running):
ssh -R 0 pom.run
Sign in and you'll get a public route URL like https://mcp.your-route-1234.pomerium.app that tunnels to your local MCP server. The widget dev server (localhost:4444) stays local — your browser loads it directly. For full tunneling details, see Tunnel to ChatGPT During Development.
3. Connect to your host
Add your public URL + /mcp as a connector in your MCP host. For example, in ChatGPT:
- Go to Settings → Connectors → Add Connector
- Enter your public URL:
https://mcp.your-route-1234.pomerium.app/mcp - Save the connector
- Start a new chat, add your app, and test with:
echo Hi there!

Other hosts (Claude, VS Code, Goose) follow the same pattern — add a connector pointing to your /mcp endpoint.
4. Build your own tools and widgets
The template's echo tool shows the full pattern. The key pieces when adding your own tool:
Register a tool with UI binding — use registerAppTool to declare the tool and its widget in one place:
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 },
};
},
);
Register a widget resource — the text/html;profile=mcp-app MIME type is required for MCP hosts to render the widget:
registerAppResource(
server,
"ui://my-widget",
"ui://my-widget",
{ mimeType: RESOURCE_MIME_TYPE },
async () => ({
contents: [
{
uri: "ui://my-widget",
mimeType: RESOURCE_MIME_TYPE, // 'text/html;profile=mcp-app'
text: await readWidgetHtml("my-widget"),
},
],
}),
);
Widget entry point — React component in widgets/src/widgets/my-widget.tsx using the App class from @modelcontextprotocol/ext-apps:
import { App } from "@modelcontextprotocol/ext-apps";
import { StrictMode, useEffect, useState } from "react";
import { createRoot } from "react-dom/client";
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>
);
}
const rootElement = document.getElementById("my-widget-root");
if (rootElement) {
createRoot(rootElement).render(
<StrictMode>
<MyWidget />
</StrictMode>,
);
}
The build auto-discovers all files matching widgets/src/widgets/*.{tsx,jsx} and bundles them with their mounting code.
See the template README for the complete guide: project structure, App API reference, display modes, inline widget assets, Storybook, testing, environment variables, and troubleshooting.
Inline widget assets
Some hosts (e.g., Claude) require fully self-contained HTML — external <script> and <link> tags won't load inside their sandboxed iframes. Use inline mode for these hosts or when sharing your work remotely via pom.run:
npm run dev:inline
This inlines JS/CSS as <script>/<style> blocks and converts local images to data URIs. The widget build runs in watch mode so file changes are automatically rebuilt.
Inline mode is not needed in production — once deployed to a public URL, hosts fetch widget assets directly via normal URLs.
For production deployment
You need two Pomerium routes — one for the MCP server (auth-gated) and one for the widgets (public):
runtime_flags:
mcp: true
routes:
# MCP server — fine-grained authorization required for tool calls
- from: https://my-mcp-app.your-domain.com
to: http://my-mcp-app:8080/mcp
name: My MCP App (server)
mcp:
server: {}
policy:
allow:
and:
- domain:
is: company.com
# Widget assets — must be public so the MCP host can render iframes without credentials
- from: https://my-mcp-app-ui.your-domain.com
to: http://my-mcp-app-widgets:4444
name: My MCP App (widgets)
allow_public_unauthenticated_access: true
The MCP server URL you register in your host points to the first (auth-gated) route. Widget resources served by your MCP server reference the second (public) route. Never put the widget route behind an auth policy — MCP hosts cannot forward credentials when loading iframe content.
See Protect an MCP Server for the full setup guide.
Sample repos and next steps
- pomerium/chatgpt-app-typescript-template — Starter template for MCP Apps with the official ext-apps SDK — full README with project structure, API reference, testing, Docker, and troubleshooting
- MCP Apps extension spec — Official standard for interactive UI in MCP hosts
- ext-apps SDK API reference — TypeScript API docs for
@modelcontextprotocol/ext-apps - MCP App Quick Start — 5-minute tutorial to build and test a secure MCP App with pom.run
- Tunnel to ChatGPT During Development — pom.run tunneling setup details
- Protect an MCP Server — Deploy permanently behind Pomerium
- MCP Full Reference — Token types, session lifecycle, configuration details