Skip to main content
Glama
TejasQ

Web Summer Camp 2026 Schedule Server

by TejasQ
README.md
# MCP Apps Workshop — Web Summer Camp 2026 schedule

A reference implementation for the workshop **"Building MCP Apps: Branded
Experiences Inside ChatGPT"** — a full ChatGPT app built on the [OpenAI
Apps SDK][sdk] (which rides on top of the [Model Context Protocol][mcp]).

The finished app surfaces the [Web Summer Camp 2026][wsc] JavaScript track
schedule inside ChatGPT and lets attendees buy a conference ticket, all
through natural language.

> This repo is the workshop **answer key**. Each block has a matching
> `checkpoint/0N` branch — check out that branch to see the finished state
> after block *N*, or diff against the previous branch to see exactly
> what changed.

## What you'll build

By the end you have an MCP server that ChatGPT talks to over HTTP, exposing:

| Tool             | Kind     | What it does                                                     |
| ---------------- | -------- | ---------------------------------------------------------------- |
| `list_talks`     | data     | Returns the schedule as JSON so the model can answer questions.  |
| `render_schedule`| render   | Same data, but attaches a widget so ChatGPT paints the UI.       |
| `buy_ticket`     | mutation | Buys an early-bird or standard pass. OAuth-gated in production.  |

…and a widget rendered inside ChatGPT that groups the schedule by day,
highlights time slots with tabular numerals, and swaps a **Buy ticket**
CTA for a **You're going ✓** confirmation once the mutation lands.

## Workshop map

| Block | Focus                             | Branch            | Command                       |
| ----- | --------------------------------- | ----------------- | ----------------------------- |
| 1     | Warmup + environment gate         | `checkpoint/01`   | `npm run warmup`              |
| 2     | Build the MCP server              | `checkpoint/02`   | `npm run dev`                 |
| 3     | Connect to ChatGPT via ngrok      | `checkpoint/03`   | `ngrok http 8787`             |
| 4     | Ship the widget                   | `checkpoint/04`   | `npm run dev` + reload app    |
| 5     | Add the `buy_ticket` mutation     | `checkpoint/05`   | `npm run dev` + reload app    |
| 6     | OAuth for the mutation            | `main`            | `AUTH_MODE=oauth npm run dev` |

## Quickstart

```bash
nvm use              # Node 20+
npm install
cp .env.example .env # OPENROUTER_API_KEY unlocks block-1 warmup
npm run doctor       # verifies Node, ngrok, and that the server boots
npm run warmup       # first block — stream tokens from an LLM
```

Then jump to a checkpoint:

```bash
git checkout checkpoint/02   # or 03, 04, 05, or main for the full app
npm run dev
```

Everything runs with **zero secrets** by default (`AUTH_MODE=noauth`).
OAuth is strictly additive — set `AUTH_MODE=oauth` plus the `IDP_*` env
vars from `.env.example` to switch it on.

## Repo layout

```
server/           MCP server
  index.ts        HTTP entry, mounts POST /mcp
  http.ts         CORS + JSON helpers
  store.ts        In-memory data + seed
  schemas.ts      Zod schemas shared across tools
  widget.ts       Registers the widget as an MCP App resource
  tools/          One file per tool
  auth/           JWKS Bearer verification (checkpoint/06)
web/
  public/         The widget HTML — served to ChatGPT as-is
scripts/
  doctor.ts       Pre-flight environment check
warmup.ts         Block-1 streaming warmup
```

## Connect to ChatGPT

The Apps SDK is in preview. You enable Developer Mode once, then treat every
change to the server as a "reload the connector" cycle.

1. **Run the server locally.**

   ```bash
   npm run dev          # tsx watch, PORT=8787
   ```

2. **Expose it publicly.** ChatGPT needs to reach your server, so tunnel
   through ngrok:

   ```bash
   ngrok http 8787
   ```

   Copy the `https://…ngrok.app` URL. Your connector URL is that + `/mcp`.

3. **Enable Developer Mode** in ChatGPT once (Settings → Advanced →
   Developer Mode). This unlocks the "Create connector" flow.

4. **Create the connector.** Settings → **Apps and Connectors** →
   **Create**. Name it (e.g. "WSC Schedule — local"), paste the ngrok URL
   with `/mcp`, leave auth as **No authentication**, and Create.

5. **Enable the connector in a chat.** In a new chat, click **+** → toggle
   the connector on. Then ask *"what's on the JavaScript track Friday?"* or
   *"when is Tejas's talk?"*.

### The refresh-after-every-change rule

ChatGPT caches tool descriptors and widget HTML aggressively. Whenever you
change server code:

  1. Save (tsx watch restarts the server).
  2. In ChatGPT, click the **connector chip** in the composer → **Refresh**.
  3. In stubborn cases, start a new chat.

If you change the widget HTML *without* changing its `ui://` URI, ChatGPT
may serve the cached copy indefinitely. Bump the URI (e.g.
`ui://widget/schedule-v2.html`) when you want to force a hard reload — the
Apps SDK docs recommend this pattern explicitly.

### Verify without ChatGPT

```bash
# Health
curl -s http://localhost:8787/ | jq

# Initialize
curl -s -X POST http://localhost:8787/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"0.0.0"}}}' | jq

# tools/list
curl -s -X POST http://localhost:8787/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' | jq
```

Or use the [MCP Inspector][inspector] — its Auth panel is invaluable when
you get to block 6.

## How the widget talks to the server

The widget lives inside an iframe that ChatGPT hands to your MCP server.
It never fetches your backend directly — it can't, because the user's
Bearer token isn't in the iframe. Everything goes through the host:

```
                       structuredContent
     MCP server  ────────────────────────►  window.openai.toolOutput
                                             │
                                             │ read on mount +
                                             │ on 'openai:set_globals'
                                             ▼
                                          widget renders
                                             │
                                             │ user clicks "Buy standard"
                                             ▼
                                    window.openai.callTool(
                                      'buy_ticket', { tier }
                                    )
                                             │
                       ChatGPT forwards       │
     MCP server  ◄──────────────────────────┘
        │
        └─► tool handler writes to store, returns updated structuredContent
                        │
                        └────► resolves widget's callTool promise
                                             │
                                             ▼
                             widget swaps returned data into state,
                             re-renders in the "you're going" branch
```

Three details from `web/public/schedule-widget.html` worth calling out:

- **The subscription.** We read `window.openai.toolOutput` on boot, then
  listen for the `openai:set_globals` custom event to reactively pick up
  new toolOutput (or new theme, or new displayMode) without polling.
  This is the vanilla equivalent of the SDK's `useOpenAiGlobal` hook.
- **The mutation returns the whole schedule.** `buy_ticket` returns the
  same shape as `render_schedule` — schedule + ticket. The widget just
  overwrites state with the returned object; there's no diffing or merge
  logic to get wrong.
- **The gotcha.** In OAuth mode the widget cannot make an authenticated
  fetch to your MCP server — the user token doesn't cross the iframe
  boundary. Always route mutations through `window.openai.callTool` and
  let the host attach the token.

## The OAuth path

`AUTH_MODE=noauth` is fine for the whole workshop up to block 5. Block 6
switches on OAuth for the mutation only; the schedule tools stay open so
"list my talks" still works before login.

```bash
AUTH_MODE=oauth \
IDP_KIND=stytch \
IDP_ISSUER=https://your-project.stytch.com \
IDP_AUDIENCE=https://your-mcp.example.com \
IDP_REQUIRED_SCOPE=tickets.write \
MCP_RESOURCE_URL=https://your-mcp.example.com \
npm run dev
```

Under the hood, on every request:

1. **HTTP peeks at the JSON body.** If it isn't a `tools/call` for a
   gated tool (currently just `buy_ticket`), no auth is required.
2. **The Bearer token is extracted** from `Authorization: Bearer …`.
3. **JWKS is fetched and cached** from `IDP_JWKS_URI` (defaults to
   `${issuer}/.well-known/jwks.json`).
4. **`jose.jwtVerify` verifies** signature, issuer, audience, and expiry
   in one call.
5. **Scopes are checked** against `IDP_REQUIRED_SCOPE`. We look at
   `scope` (space-delimited), `scp`, `scopes`, and `permissions` — every
   IdP formats them differently.
6. **The `sub` claim becomes the ticket owner.** `store.buyTicket(subject,
   …)` uses it as the key, so each authenticated user gets their own
   ticket state.
7. **Missing / invalid / expired token → 401.** The response carries a
   `WWW-Authenticate: Bearer resource_metadata="…"` header pointing at
   `/.well-known/oauth-protected-resource`, which tells ChatGPT which
   auth server to send the user to.
8. **ChatGPT walks the discovery chain.** Protected Resource metadata →
   authorization server metadata → OAuth 2.1 + PKCE authorization code
   flow → the user logs in → a fresh token attaches to the next
   `tools/call` and the mutation runs.

### IdP tenant setup

You need a tenant that speaks OAuth 2.1 with authorization code + PKCE
and publishes JWKS. Both adapters do; the setup steps differ.

<details>
<summary><strong>Stytch (recommended for the workshop)</strong></summary>

1. Create a Stytch Consumer project (Live or Test).
2. **Connected Apps → Create new app**. Name it "WSC Schedule".
3. Add the redirect URI ChatGPT uses:
   `https://chatgpt.com/connector_platform_oauth_redirect`.
4. **Custom scopes** → add `tickets.write` with a description like
   "Buy conference tickets".
5. Grab **Project ID** and **Public token** — you'll set these in `.env`:
   ```env
   AUTH_MODE=oauth
   IDP_KIND=stytch
   IDP_ISSUER=https://<your-project>.stytch.com
   IDP_AUDIENCE=https://<your-mcp-domain>   # your server's public origin
   IDP_REQUIRED_SCOPE=tickets.write
   MCP_RESOURCE_URL=https://<your-mcp-domain>
   ```
6. Add a test user in Stytch. That user is who you'll log in as when
   ChatGPT prompts you.
7. Restart the server. On boot you should see:
   `[auth] oauth enabled · idp=Stytch · issuer=… · scope=tickets.write`.
8. In ChatGPT, refresh the connector. Try "buy me an early-bird ticket"
   → ChatGPT surfaces the Stytch login → after login the tool runs and
   the widget shows the confirmation card.

Full guide: [Stytch's Apps SDK walkthrough][stytch].

</details>

<details>
<summary><strong>Auth0</strong></summary>

1. Create an Auth0 tenant.
2. **Applications → Regular Web Application** for the ChatGPT client.
   Add the redirect URI:
   `https://chatgpt.com/connector_platform_oauth_redirect`.
3. **Applications → APIs → Create API** for this MCP server:
   - Identifier: `https://<your-mcp-domain>` (this becomes the `aud`
     claim → `IDP_AUDIENCE`).
   - Signing algorithm: RS256.
   - Add a permission `tickets.write`.
4. In your `.env`:
   ```env
   AUTH_MODE=oauth
   IDP_KIND=auth0
   IDP_ISSUER=https://<your-tenant>.us.auth0.com/
   IDP_AUDIENCE=https://<your-mcp-domain>
   IDP_REQUIRED_SCOPE=tickets.write
   MCP_RESOURCE_URL=https://<your-mcp-domain>
   ```
5. Add a test user in Auth0.
6. Restart the server; you should see the Auth0 label at boot.
7. Refresh the connector in ChatGPT and try to buy.

Full guide: [Auth0's MCP AI docs][auth0].

</details>

## Troubleshooting

**502 in the connector wizard.** The wizard hit an unimplemented route
and ngrok/your proxy returned 502 instead of 404. Our server serves
explicit `404 · Cache-Control: no-store` for
`/.well-known/oauth-*` in noauth mode to avoid this. If you see 502
anyway, tail `ngrok` — it's usually your server crashed, not the wizard.

**Tool doesn't appear in ChatGPT.** Click the connector chip → Refresh.
If it still doesn't appear, start a new chat. The tool list is cached
per-conversation.

**Widget stays on the old version.** ChatGPT caches widget HTML by
`ui://` URI. Bump the URI (e.g. `schedule-v2.html` → `schedule-v3.html`)
and the host re-fetches. `server/widget.ts` isolates the URI so this is
a one-line change.

**401 loop in oauth mode.** Check the boot log — bad `IDP_ISSUER` or
`IDP_AUDIENCE` values won't fail at boot but will fail at verify time.
The [MCP Inspector's Auth panel][inspector] shows the exact 401 body,
including the `WWW-Authenticate` challenge.

**"Insufficient scope" after login.** The test user has a token, but
the token doesn't carry `tickets.write`. Grant the scope to the user in
your IdP, or change `IDP_REQUIRED_SCOPE`.

[inspector]: https://modelcontextprotocol.io/legacy/tools/inspector
[stytch]: https://stytch.com/blog/guide-to-authentication-for-the-openai-apps-sdk/
[auth0]: https://auth0.com/ai/docs/mcp/intro/overview

---

The [Apps SDK is in preview][sdk] and moves fast. If anything above
diverges from the docs, follow the docs and open an issue.

[sdk]: https://developers.openai.com/apps-sdk
[mcp]: https://modelcontextprotocol.io
[wsc]: https://websummercamp.com/2026/program/js