Tic-tac-toe MCP Server
by cjayasur
README.md
# Tic-tac-toe against the model
A complete app that runs **inside ChatGPT** (and Claude): an MCP server in Python plus a React
widget that the chat app draws in a sandboxed iframe. You tap the board, the model answers with
its move, and the board updates.
About 800 lines in three files:
| File | What it does |
|---|---|
| `main.py` | The MCP server: game rules, four tools, and the one HTML file it hands out via `resources/read`. |
| `web/src/bridge.js` | The only code that knows which chat app it's in: `window.openai` (ChatGPT) or JSON-RPC over `postMessage` (MCP Apps / Claude). |
| `web/src/App.jsx` | The game screen in plain React: state, a 3×3 grid, and a loop that hands the turn to the model. |
## How it works
```
ChatGPT app main.py
+-------------------------------+ +---------------------------+
| model ------------------------+-- tools/call -->| tictactoe_start / _move |
| iframe: React widget |<-resources/read-| widget.html (Vite build) |
| tap -- callTool ------------+-- tools/call -->| tictactoe_play / _state |
| "your move" -- sendFollowUpMessage --> model | GAMES{} = the real board |
+-------------------------------+ +---------------------------+
```
1. **Tools with `_meta`.** `tictactoe_start` carries `openai/outputTemplate: ui://widget/tictactoe.html`,
so ChatGPT draws the widget. `tictactoe_play` and `tictactoe_state` are `openai/visibility: private`:
only the widget can call them, so only a tap can place an X.
2. **The widget ships as one string.** Vite + `vite-plugin-singlefile` builds React and the CSS into
one ~160 KB HTML file, returned by `resources/read` as `text/html+skybridge`.
3. **One tool result, three readers.** `content` is text for the model (ending in "Your move: call
tictactoe_move…"), `structuredContent` is JSON for the widget, `_meta` is for the host.
4. **The loop.** A tap calls `tictactoe_play`, then `sendFollowUpMessage("I played X in cell 5 …
your move")` starts a model turn. The model calls `tictactoe_move`, and the widget polls
`tictactoe_state` every 1.2 s until the O appears (servers can't push to widgets).
5. **The server owns the board.** It checks turns, rejects taken cells and decides wins. The widget
reloads the board from the server on every mount, because ChatGPT can re-create a widget and hand
it a stale result.
`TTT_HOST=chatgpt` advertises the Apps SDK metadata; `TTT_HOST=claude` advertises MCP Apps metadata
(`_meta.ui.resourceUri`, `text/html;profile=mcp-app`). Same image, same widget.
## Run it
```bash
cp .env.example .env # set PUBLIC_HOST
docker compose up -d --build # ChatGPT server on 127.0.0.1:8094, Claude on :8093
```
ChatGPT and Claude need a public HTTPS URL: put nginx (see `nginx/ttt.conf.example`), a tunnel such
as ngrok, or any reverse proxy in front.
Widget development without Docker:
```bash
cd web && npm install && npm run build # writes web/dist/index.html
cp dist/index.html ../widget.html
cd .. && pip install -r requirements.txt && uvicorn main:app --port 8000
```
## Add it to ChatGPT
1. chatgpt.com → **Settings → Apps & Connectors → Advanced** → turn on **Developer mode**
(paid plans). On a phone, first choose **Request Desktop Website** in Safari ("aA") or Chrome (⋯).
2. **Create**: any name, URL `https://<your-host>/ttt/mcp`, **No authentication**, tick "I trust" → Create.
3. In a chat: `@Tic-tac-toe let's play`, then tap a square.
ChatGPT caches the tool list and widget when the connector is created: after any change, delete
the connector and add it again.
**Try the live one:** `https://api.palomaressoftware.com/ttt/mcp` (ChatGPT) or
`https://api.palomaressoftware.com/ttt-claude/mcp` (Claude).
## Known differences between hosts
- **ChatGPT** plays hands-free: `sendFollowUpMessage` starts a model turn after each tap.
- **Claude** (MCP Apps, tested on iOS): `ui/message` doesn't start a model turn, so you type
"your move" after each tap. The widget asks you to after 6 s.
- In **voice mode** the model may announce a move without calling the tool; the tool
descriptions forbid it, and only the server's board counts.
## Tracing (optional)
`tracing.py` prints one JSON line per request (method, tool, timing, trace id) and a `game` event per
move. Set `SPLUNK_HEC_URL` and `SPLUNK_HEC_TOKEN` to also send them to Splunk. It logs ids and
timings only.
## License
MIT
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues