Skip to main content
Version: vNext (upcoming release)

Develop a Secure MCP App with pom.run

This guide walks you through building a secure MCP App using the Pomerium MCP App template and ssh -R 0 pom.run. pom.run is a hosted reverse SSH tunnel that gives your local MCP server a public URL and handles OAuth automatically. No auth code to write, no certificate management. The quick start gets you a working app you can test in ChatGPT or Claude.ai in about 5 minutes. The rest covers building your own tools, sharing what you're building, and deploying your MCP server behind Pomerium when you're ready to ship.

Quick Start

You need:

  • Node.js 24+ (verify with node -v)
  • SSH client (pre-installed on macOS/Linux; use WSL2 on Windows)
  • A ChatGPT Plus subscription or Claude.ai account

1. Open the tunnel

In a terminal, run:

ssh -R 0 pom.run

On first run, you'll see a sign-in prompt:

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 to sign in. If this is your first time, you'll be prompted to create a Pomerium account. You can sign up with email, Google, or GitHub. Authentication is handled by Pomerium's hosted authenticate service, so there's nothing to configure on your end.

After authenticating, your terminal shows the Pomerium TUI. Find your public URL in the Port Forward Status section:

Opening an SSH tunnel with pom.run

Status:  ACTIVE
Remote: https://echo.first-wallaby-240.pomerium.app
Local: http://localhost:8080

Keep this terminal open. The tunnel stays active as long as the SSH session is running. See Reverse Tunneling for more detail on the TUI and how the tunnel works.

If the URL is cut off, you can right click in the TUI and select Copy Remote URL to get the full URL.

Right-click menu in pom.run TUI showing Copy Remote URL option

2. Clone and start the template

In a new terminal:

git clone https://github.com/pomerium/chatgpt-app-typescript-template your-mcp-app
cd your-mcp-app
npm install

Start the dev server:

npm run dev

This starts two processes:

  • MCP server on http://localhost:8080
  • UI dev server on http://localhost:4444

You should see something similar to this in your terminal:

❯ npm run dev

> chatgpt-app-typescript-template@1.0.0 dev
> concurrently "npm run dev:server" "npm run dev:widgets"

[1]
[1] > chatgpt-app-typescript-template@1.0.0 dev:widgets
[1] > npm run dev --workspace=widgets
[1]
[0]
[0] > chatgpt-app-typescript-template@1.0.0 dev:server
[0] > npm run dev --workspace=server
[0]
[0]
[0] > chatgpt-app-server@1.0.0 dev
[0] > tsx watch src/server.ts
[0]
[1]
[1] > chatgpt-app-widgets@1.0.0 dev
[1] > vite
[1]
[1]
[1] Found 1 widget(s):
[1] - echo
[1]
[1] 3:25:38 PM [vite] (client) Re-optimizing dependencies because lockfile has changed
[1]
[1] VITE v7.3.0 ready in 261 ms
[1]
[1] ➜ Local: http://localhost:4444/
[1] ➜ Network: use --host to expose
[0] [dotenv@17.2.3] injecting env (0) from .env -- tip: 🔐 encrypt with Dotenvx: https://dotenvx.com
[0] [19:25:38] INFO: Starting MCP App Template server
[0] port: 8080
[0] nodeEnv: "development"
[0] logLevel: "info"
[0] assetsDir: "/Users/some-dev/dev/oss/chatgpt-app-typescript-template/assets"
[0] baseUrl: ""
[0] inlineDevMode: false
[0] [19:25:38] INFO: Server started successfully
[0] port: 8080
[0] mcpEndpoint: "http://localhost:8080/mcp"
[0] healthEndpoint: "http://localhost:8080/health"

3. Connect and test

  1. In ChatGPT, go to Settings → Apps → Advanced settings and enable Developer mode
  2. Click Create app
  3. Set MCP Server URL to your tunnel URL + /mcp, e.g. https://echo.first-wallaby-240.pomerium.app/mcp
  4. Set Authentication to OAuth
  5. Check the acknowledgment and save

Registering an MCP app in ChatGPT

Start a new chat and send: @Echo today is a great day, where Echo is the name of the app (MCP server) you registered.

You should see the message displayed in an interactive widget. That's a working MCP App.


From here, you can stop. You have a working app you can build on. The sections below cover building your own tools, sharing your work, and deploying to production.


Building Your Own Tools

The echo tool in the template shows the full pattern. Here's what you need when adding your own.

Tool response

Return structuredContent for the widget and a _meta.ui.resourceUri pointing to your widget resource:

return {
content: [{ type: "text", text: "Human-readable result" }],
structuredContent: { result: args.input },
_meta: {
ui: { resourceUri: "ui://my-widget" },
},
};

Widget entry point

Create widgets/src/widgets/my-widget.tsx. The build system auto-discovers all files matching widgets/src/widgets/*.{tsx,jsx}:

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" : ""}>
<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>,
);
}

Widget resource registration

Register the widget resource on the server. The RESOURCE_MIME_TYPE constant used below (imported from @modelcontextprotocol/ext-apps/server) is text/html;profile=mcp-app, the MIME type MCP hosts expect for widget resources:

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"),
},
],
}),
);

Then build your widgets and restart the server:

npm run build:widgets
npm run dev:server

External resources in widgets

MCP hosts render widgets in sandboxed iframes with a strict Content Security Policy. Remote images and API calls are blocked by default. To allow them, declare the domains in your resource registration:

return {
contents: [
{
uri: resourceUri,
mimeType: RESOURCE_MIME_TYPE,
text: html,
_meta: {
ui: {
csp: {
resourceDomains: ["https://cdn.example.com"],
connectDomains: ["https://api.example.com"], // for fetch/XHR
},
},
},
},
],
};

List every domain explicitly. Wildcards aren't supported. If a URL redirects (e.g. https://picsum.photos to https://fastly.picsum.photos), include both.

Testing without a host

You can inspect your server locally before connecting to ChatGPT or Claude.ai:

npm run inspect

This opens the MCP Inspector in your browser where you can list tools, invoke them, and verify responses.

Production Deployment

pom.run is for development, not a deployment target. It's a hosted reverse SSH tunnel. When you're ready to ship, you need two things:

MCP server: must sit behind a Pomerium route with authentication and policy enforcement. Your options:

  • Pomerium Zero: managed control plane, fastest path to production
  • Open core: self-hosted, full control
  • Pomerium Enterprise: for organizations that need the enterprise console

Widget assets: must be publicly accessible because MCP hosts render widgets in sandboxed iframes and can't forward credentials. Serve them from a Pomerium public route, or host them anywhere static assets work: Netlify, Vercel, a CDN via BASE_URL. Your call.

Build

npm run build

This compiles widget bundles with content hashing and the server TypeScript. Outputs:

  • assets/: optimized widget bundles
  • server/dist/: compiled server code

Start the production server:

NODE_ENV=production npm start

Docker

The template includes a multi-stage Dockerfile:

docker build -f docker/Dockerfile -t my-mcp-app:latest .
docker-compose -f docker/docker-compose.yml up -d

Verify it's healthy:

curl http://localhost:8080/health

Pomerium routes and deployment

Troubleshooting

HTTP 503 errors

This usually means the tunnel, the local MCP server, or both aren't reachable:

  • Tunnel isn't running — make sure ssh -R 0 pom.run is running in a terminal and the session is active.
  • MCP server isn't running — if the tunnel is up but you're still getting 503s, confirm the MCP server is running locally on http://localhost:8080. Start it with npm run dev.
  • Both are down — restart both the tunnel and the dev server.

MCP host can't connect to your server

If you're not getting a 503 but your MCP host (e.g. ChatGPT) still can't connect, check that you're using the full MCP endpoint URL with the /mcp path. For example, use https://echo.first-wallaby-240.pomerium.app/mcp, not https://echo.first-wallaby-240.pomerium.app.

Widget not rendering

Check that your resource registration uses text/html;profile=mcp-app as the MIME type. Verify assets/ exists and contains built widget files (ls assets/). Rebuild with npm run build:widgets and restart the server.

Widget renders in ChatGPT but not Claude.ai

Claude.ai's Content Security Policy (CSP) blocks loading resources from localhost, so widgets that reference external scripts or stylesheets on localhost:4444 won't render. Run npm run dev:inline instead of npm run dev — inline mode bundles all JavaScript and CSS directly into the widget HTML, producing self-contained files that work within Claude's CSP restrictions.

Remote images or API calls not loading in widgets

MCP hosts render widgets in sandboxed iframes with strict CSP. If remote images or fetch calls aren't working, you need to allowlist their domains in your widget resource registration:

return {
contents: [
{
uri: resourceUri,
mimeType: RESOURCE_MIME_TYPE,
text: html,
_meta: {
ui: {
csp: {
resourceDomains: ["https://cdn.example.com"],
connectDomains: ["https://api.example.com"],
},
},
},
},
],
};

List every domain explicitly — wildcards aren't supported. If a URL redirects (e.g. https://picsum.photoshttps://fastly.picsum.photos), include both the original and the redirect target.

Tool not appearing in host

Test locally with npm run inspect first. If the tool doesn't show in the inspector, check server logs for schema errors.

If it works in the inspector but not in your MCP host, the host is likely using a cached tool list. MCP hosts cache tools when you first connect, so after adding or modifying tools you need to refresh. For example, in ChatGPT, go to Settings → Apps, select your app, and click Refresh under the Information section:

ChatGPT app settings showing the Refresh button under the Information section

You can also verify your registered tools and widget resources are correct from the app's detail view:

ChatGPT developer mode showing registered actions and widget templates

Widget not mounting (blank iframe)

Check that the root element ID in your widget's document.getElementById() call matches the ID in the HTML template. A mismatch causes the widget to silently fail to mount with no error in the host.

Text response works but no widget renders

Make sure your tool response includes _meta.ui.resourceUri pointing to a registered widget resource. Without it, the host displays the text content but has no widget to render.

Additional Resources

Feedback