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
This server cannot be deployed
Maintenance
ActivityStale
ResponsivenessNo issues