Skip to main content
Glama
TejasQ

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!