CityJS London 2026 Companion
by TejasQ
README.md
# _Basically_, [MCP Apps](https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/mcp-apps)
So you know how MCP servers return text and JSON and stuff? Well, MCP Apps take that a step further: your tools can return **full HTML widgets** that render _directly inside ChatGPT_. Instead of the model dumping a wall of JSON at the user, they see an actual UI. Cards, grids, timelines -- whatever you want.
This is literally just meant to be a POC and nothing too serious. It's a CityJS London 2026 conference companion app: schedule, speakers, talk search -- all rendering as rich, themed UI inside ChatGPT.
## Run it
```bash
./start.sh
```
That's it. One command. It installs deps, starts the server, opens a [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/) tunnel, and hands you a URL to paste into ChatGPT. You need [Node.js 18+](https://nodejs.org) and cloudflared (`brew install cloudflared` on Mac).
You'll see something like this:
```
┌─────────────────────────────────────────────────────────┐
│ │
│ YOUR MCP ENDPOINT: │
│ │
│ https://something-random.trycloudflare.com/mcp │
│ │
│ NOW GO ADD IT TO CHATGPT: │
│ │
│ 1. Open chatgpt.com │
│ 2. Click the tools icon (wrench) in the input bar │
│ 3. Click 'Add MCP Server' │
│ 4. Paste the URL above │
│ 5. Ask: 'What's the CityJS London schedule?' │
│ │
└─────────────────────────────────────────────────────────┘
```
Then ask ChatGPT things like _"show me the speakers"_ or _"tell me about Douglas Crockford's talk"_ or _"find talks about AI"_ and watch the widgets appear.
## Ok but how do I learn about MCP Apps
THE SOURCE CODE IS NOT SCARY! Go through it. There are _basically_ 3 relevant files (and they're small!):
| File | What it does |
|---|---|
| [server.js](server.js) | The MCP server. Registers widgets, registers tools, binds them together. Start here. |
| [widgets/schedule.html](widgets/schedule.html) | An HTML widget that renders a conference timeline. Read the `<script>` tag at the bottom. |
| [widgets/speakers.html](widgets/speakers.html) | An HTML widget that renders a speaker card grid. Same pattern as above. |
| [widgets/speaker-detail.html](widgets/speaker-detail.html) | An HTML widget for a single speaker's full profile card. |
GO READ THEM. IT'S FUN, REALLY!
## How it _basically_ works
An MCP App is just an MCP server that _also_ serves HTML widgets. When ChatGPT calls your tool, it renders your widget and pipes the tool's output data into it. Three things make this happen:
### 1. You write an HTML widget
A self-contained HTML file with inline CSS and JS. It receives data from ChatGPT and renders it. That's all it does. Look at [widgets/speakers.html](widgets/speakers.html) -- it's just a `render(data)` function and some CSS.
The widget picks up data from ChatGPT like this:
```js
// ChatGPT puts tool output here when the widget loads
tryRender(window.openai?.toolOutput);
// Or fires this event slightly later
window.addEventListener("openai:set_globals", (e) => {
tryRender(e.detail?.globals?.toolOutput);
});
```
It also picks up the theme (`window.openai?.theme`) so it matches ChatGPT's light/dark mode automatically.
### 2. You register the widget as a resource
In [server.js](server.js), you tell the MCP host "hey, I have this widget":
```js
server.registerResource(
"schedule-widget",
"ui://cityjs/schedule.html",
{ mimeType: "text/html;profile=mcp-app" }, // <-- this MIME type is the magic
async () => ({
contents: [{
uri: "ui://cityjs/schedule.html",
mimeType: "text/html;profile=mcp-app",
text: scheduleWidgetHtml, // the raw HTML string
}],
})
);
```
The MIME type `text/html;profile=mcp-app` is what turns a regular MCP server into an MCP _App_. It tells the host "this is a renderable widget, not just a file."
### 3. You bind a tool to the widget
When you register a tool, you tell the host _which widget to render_ when the tool is called:
```js
server.registerTool(
"get_schedule",
{
title: "Get Schedule",
description: "Get the CityJS London 2026 schedule...",
inputSchema: { day: z.enum(["day1", "day2", "day3", "all"]).optional() },
_meta: {
ui: { resourceUri: SCHEDULE_URI }, // MCP spec way
"openai/outputTemplate": SCHEDULE_URI, // ChatGPT-specific way
},
},
async ({ day }) => {
return {
structuredContent: { days }, // <-- your widget receives THIS
content: [{ type: "text", text: JSON.stringify({ days }) }], // fallback for non-UI hosts
};
}
);
```
`structuredContent` is the data your widget renders. `content` is a text fallback for hosts that don't do UI yet (like Claude). Always return both.
And that's _basically_ it. Widget + resource + tool binding = MCP App.
## The project
```
basically-mcp-apps/
start.sh <- run this. that's it.
server.js <- the MCP server. START READING HERE.
package.json
data/
data.json <- raw conference data (speakers, talks, bios)
cityjs.js <- enriches the raw data with rooms, types, etc.
widgets/
schedule.html <- conference schedule timeline widget
speakers.html <- speaker grid widget
speaker-detail.html <- individual speaker profile card widget
```
## Dependencies
- [`@modelcontextprotocol/sdk`](https://www.npmjs.com/package/@modelcontextprotocol/sdk) -- MCP server SDK
- [`zod`](https://www.npmjs.com/package/zod) -- input schema validation
- [`cloudflared`](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/) -- tunnels your localhost to the internet so ChatGPT can reach it
- Node.js 18+
No React, no build step, no bundler, no framework. Just HTML files and a Node server.
Happy hacking!
This server cannot be deployed
Maintenance
ActivityInactive
ResponsivenessNo issues