ChatGPT App Starter MCP Server
by lologocrm
README.md
# ChatGPT App Starter
**The fastest way to get a working ChatGPT App — one server file, one widget, zero build step.**
Built on the [OpenAI Apps SDK](https://developers.openai.com/apps-sdk) (MCP). Clone it, run it, connect it to ChatGPT in under 5 minutes.
  
## Why this exists
The Apps SDK docs are good but spread across a dozen pages, and the official examples assume a React + bundler setup. This starter is the opposite: **plain Node, plain HTML, every moving part visible in ~200 lines**. Read two files and you understand the whole platform. Then replace the example tool with your own logic.
## Quickstart (5 minutes)
**Prerequisites:** Node 20+, [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/) (free HTTPS tunnel, no account needed — `brew install cloudflared`).
```bash
git clone https://github.com/lologocrm/chatgpt-app-starter.git
cd chatgpt-app-starter
npm install
npm start # MCP server on http://localhost:8787/mcp
```
In a second terminal:
```bash
npm run tunnel # prints a public https://xxx.trycloudflare.com URL
```
Then connect it to ChatGPT:
1. **Settings → Apps & Connectors → Advanced settings** → enable **Developer mode** (one-time)
2. **Settings → Connectors → Create** → paste `https://xxx.trycloudflare.com/mcp`
3. Open a new chat → **+** → **More** → select your connector
4. Try: *"Show me a card titled Hello World that says it works, linking to example.com"*
ChatGPT calls your `show_card` tool and renders the widget inline. That's a ChatGPT App.
## How it works
```
ChatGPT ──HTTP──▶ server.js (MCP server)
│ ├── tool: show_card → returns structuredContent
│ └── resource: ui://widget/… → the widget HTML
└──iframe──▶ public/widget.html
└── JSON-RPC over postMessage bridge (ui/initialize,
ui/notifications/tool-result, tools/call…)
```
Every tool result carries **three payloads** — getting this split right is 80% of Apps SDK design:
| Payload | Who sees it | Use for |
|---|---|---|
| `structuredContent` | model **and** widget | The data your widget renders. Keep it small — the model reads it too. |
| `content` | model only | Optional narration for the assistant's text reply. |
| `_meta` | widget only | Large or sensitive data. **Never reaches the model.** |
### Widget-initiated actions (forms, lead capture)
The example tool is read-only. To let the **widget** trigger an action — submit a form, capture a lead — add a second tool, mark it widget-callable with `openai/widgetAccessible`, and call it from the widget with data collected **in the widget UI** (so personal data never routes through the model):
```js
// server.js — a write tool the widget is allowed to call
registerAppTool(server, "submit_request", {
title: "Submit request",
inputSchema: { email: z.string().email(), note: z.string().optional() },
annotations: { readOnlyHint: false, openWorldHint: true },
_meta: { "openai/widgetAccessible": true }, // <-- without this, the widget's call is blocked
}, async ({ email, note }) => {
/* store or forward the lead */
return { structuredContent: { ok: true } };
});
```
On the **widget side**, call the tool over the MCP Apps bridge (JSON-RPC `tools/call` via `postMessage`), with `window.openai.callTool` as a fallback for older clients. This is the pattern that actually works in the **current** ChatGPT client — `window.openai.callTool` alone silently fails on newer clients (the read path, `window.openai.toolOutput`, still works, which is why the widget renders but the button does nothing):
```js
// widget.html — a tiny JSON-RPC bridge, then call the tool with data collected in the widget UI
let _id = 0; const _pending = new Map();
window.addEventListener("message", (e) => {
const m = e.data;
if (e.source !== window.parent || !m || m.jsonrpc !== "2.0" || m.id == null) return;
const p = _pending.get(m.id); if (!p) return; _pending.delete(m.id);
m.error ? p.reject(m.error) : p.resolve(m.result);
});
function bridge(method, params) {
return new Promise((resolve, reject) => {
const id = ++_id; _pending.set(id, { resolve, reject });
window.parent.postMessage({ jsonrpc: "2.0", id, method, params }, "*");
setTimeout(() => { if (_pending.delete(id)) reject(new Error("timeout")); }, 15000);
});
}
function callTool(name, args) { // bridge first, legacy fallback
return bridge("tools/call", { name, arguments: args })
.catch((e) => (window.openai?.callTool ? window.openai.callTool(name, args) : Promise.reject(e)));
}
// on submit: await callTool("submit_request", { email, note });
```
The model only ever sees `structuredContent`, never the raw PII. Make the write **idempotent / de-duplicated server-side** (by email or a request id) so a retry — or the bridge-then-fallback path firing twice — can never create a double.
## Gotchas the docs won't shout about
- **Widget caching:** ChatGPT caches widget templates by URI. In **dev mode**, if you edit `widget.html` and nothing changes, bump `WIDGET_URI` (`card-v1` → `card-v2`). ⚠️ **Never do this on a *published* app** — the live app is pinned to the *approved* URI, so a new one can't be fetched and the widget breaks with **`Failed to fetch template`**. On a published app, ship widget changes by updating the content served at the **same** URI (redeploy). See [Shipping updates to a published app](docs/SUBMISSION.md#shipping-updates-to-a-published-app).
- **Tunnel URLs rotate:** each `npm run tunnel` gives a new trycloudflare URL — update your connector, or use a named tunnel / ngrok with a reserved domain.
- **MIME type is strict:** widget resources must be served as `text/html;profile=mcp-app` (use the `RESOURCE_MIME_TYPE` constant, don't hardcode).
- **Stateless by design:** this starter creates one MCP server + transport per request. No sessions, no sticky routing — it deploys anywhere HTTPS runs.
- **Monetization reality (2026):** no in-app purchases or digital goods yet. Link-outs to your own site are the supported pattern — the example card's CTA button is exactly that.
## Deploy to production
The server is a single Node process — any HTTPS host works:
- **[Fly.io](https://fly.io)** / **[Render](https://render.com)**: deploy `server.js` as-is (set `PORT` from env — already handled).
- **Cloudflare Workers**: needs a transport adapter (no `node:http`); PRs welcome.
Then submit for review to get listed in the ChatGPT app directory. Building the app is the easy part — **passing review is its own playbook** (screenshots to spec, real test cases, a privacy policy that actually resolves). It's all written up in **[Ship it](#ship-it-submission-approval--legal)** below, from a real approved submission.
## Ship it: submission, approval & legal
Building a working app is the first 20%; **passing OpenAI's review is the other 80%.** These guides are written from a **real, approved submission** — including the two things that got it **rejected the first time**:
- **[docs/SUBMISSION.md](docs/SUBMISSION.md)** — the six-section submission form, what each field wants, the two real rejection reasons + fixes, and the actual timeline.
- **[docs/LEGAL.md](docs/LEGAL.md)** — Privacy Policy (the **#1 silent rejection**: the URL must resolve to a *real* page, not an empty SPA shell), Terms, legal notice / mentions légales, GDPR, regulated-industry notes, and the compliance boxes you legally attest to at submit.
- **[docs/SCREENSHOTS.md](docs/SCREENSHOTS.md)** — exact specs (**706 px wide, widget-only, no baked-in prompt**) + a puppeteer script ([`scripts/generate-screenshots.mjs`](scripts/generate-screenshots.mjs)) that produces them.
- **[docs/PRODUCTION.md](docs/PRODUCTION.md)** — hardening for going live: rate-limiting, concurrency caps to protect partner quotas, structured logs (no PII), an internal `/metrics` endpoint, timeouts, and the cold-start gotcha. The *security & supervision* layer a partner or CISO will ask about.
- **[docs/DISCOVERABILITY.md](docs/DISCOVERABILITY.md)** — getting found **and invoked**: directory naming, the tool descriptions + positive/negative test cases that make ChatGPT actually fire your app, and why reliability is what earns ranking.
**The one thing to remember:** both rejections were misdiagnosed by their labels. "Test cases failed" was really a **server bug** on an edge case; "privacy incomplete" was really a **broken URL** serving an empty page. Fix the real cause, validate live, resubmit.
## Make it yours
1. Replace `show_card` in [server.js](server.js) with your tool(s) — one `registerAppTool` call each.
2. Rewrite the `render()` function in [public/widget.html](public/widget.html).
3. Bump `WIDGET_URI`. Test in developer mode. Submit.
## Resources
- [Apps SDK quickstart](https://developers.openai.com/apps-sdk/quickstart)
- [MCP server guide](https://developers.openai.com/apps-sdk/build/mcp-server)
- [Custom UX / widget bridge](https://developers.openai.com/apps-sdk/build/custom-ux)
- [UI guidelines](https://developers.openai.com/apps-sdk/concepts/ui-guidelines)
- [Model Context Protocol](https://modelcontextprotocol.io)
## License
[MIT](LICENSE) — do whatever you want with it.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessSyncing