Skip to main content
Glama
cjayasur

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