Game Creator MCP Server — Design
Status: implemented (pending deploy) · Author: design session 2026-06-17
What shipped
Worker:
worker/src/game-session-do.ts—GameSessionDODurable Object (executor WS +/execrelay + KV live registry).worker/src/mcp/oauth.ts— OAuth 2.1 AS (DCR, PKCE S256, discovery metadata, consent page) reusing the Cinevva session for login; self-contained HMAC access tokens signed withSESSION_SECRET.worker/src/mcp/tools.ts— curated tool surface; schemas reused verbatim fromshared/game-creator-tools.ts, plus the MCP-onlylist_live_sessions.worker/src/mcp/server.ts— MCP JSON-RPC (Streamable HTTP): initialize, tools/list, tools/call, resources/list+read, ping. Resolves the live game and relays to the DO.worker/src/index.ts— EnvGAME_SESSIONbinding, DO export, routes (/.well-known/oauth-*,/mcp/oauth/*,/create/mcp,/game-creator/session/:gameId/ws).worker/wrangler.toml—GAME_SESSIONDO binding + migrationv7.
Frontend (.vitepress/theme/game-creator/GameCreator.vue):
- Executor WebSocket adapter: receives
reqframes, runs them through the existingexecuteLocalTool(same path as in-app chat), returnsresframes; 45s heartbeat, auto-reconnect, take-over handling. - "External agent" toggle in the toolbar overflow menu (persisted in localStorage), with connecting/live color states. Read-only sessions can't be driven.
Deploy & connect
cd worker && npx wrangler deploy(creates theGameSessionDOmigration v7).- No new secrets needed — tokens are signed with the existing
SESSION_SECRET; login reuses the existing Auth0 session. - In Cursor / Claude, add a remote MCP server:
https://api.cinevva.com/create/mcp. The client runs OAuth (DCR → Auth0-backed consent → token) automatically. - Open a game in the Cinevva game creator and toggle External agent on (toolbar ⋯ menu). The tab connects as executor.
- In the client, call
list_live_sessions(or just start editing — the single live session auto-selects).
Known follow-ups (not blocking)
- Console resource (
game://{id}/console) is empty until the tab pushesconsoleevtframes;read_console_logstool already works live in the meantime. - Async generators (
generate_*) return whateverexecuteLocalToolreturns synchronously; if any kick off background jobs, add aget_job_statustool. - Cookie/domain:
/mcp/oauth/authorizereads thesessioncookie onapi.cinevva.com. If login lives only on the app domain, confirm the cookie is scoped to.cinevva.comor wire thereturnTologin bounce.
Goal
Let a user connect an external MCP client (Cursor, Claude Desktop) to their live game creator session, so the external agent can read/edit files, search & import assets, run code in the game, and take screenshots — and the user watches the game hot-reload in real time in their open browser tab.
Decisions (locked)
- Strictly live. The browser tab is the only executor. No headless / server-side fallback. If no tab is connected for a game, tools error with "open the game creator".
- External client is the brain. Claude/Cursor runs the agent loop and calls curated tools directly. We do not reuse the worker's gpt-5.4 chat loop on this path.
- OAuth via Auth0. The MCP client authenticates as the user; the token scopes to that user's games. No per-session pairing code, no long-lived API key.
Why this is different from a normal MCP server
Today the worker is just a relay: the LLM runs in the worker (/game-creator/chat, game-creator.ts:2657), but tools execute client-side in the browser against IndexedDB and the live <iframe> (read_files, edit_files, execute_js, take_screenshot). The worker holds no live session object; each chat POST is stateless.
So the MCP server is a bridge, not an executor. It brokers tool calls from the external agent into the user's open tab, which runs the existing client-side tool handlers. The tab stays the source of truth for live state.
Architecture
Claude / Cursor
│ remote MCP (Streamable HTTP) + OAuth bearer
▼
Worker /create/mcp ← MCP transport, tool dispatch
│ Durable Object RPC (stub.fetch)
▼
GameSession DO (one per gameId) ← the "live session" object (NEW)
│ WebSocket
▼
Browser tab (GameCreator.vue) ← executor: runs client-side tools (NEW socket)
live iframe + IndexedDB filesComponents
1. GameSession Durable Object (new) — env.GAME_SESSION.idFromName(gameId). Holds:
- the browser executor WebSocket (at most one owner tab; later tabs replace/queue)
- a pending-request map (
requestId -> {resolve, reject, timer}) - a small live cache: last screenshot, rolling console buffer, recent file-change events
- the owning
userId(set on executor connect, checked against MCP caller)
Methods (over stub.fetch):
POST /exec { tool, args }→ relays to executor, awaits result, returns itGET /status→{ connected: bool, userId, gameId, lastActivity }POST /attach-mcp//detach-mcp→ track subscribers for push events (optional v2)
2. Browser executor (addition to GameCreator.vue) — when the user enables the external agent (toggle in the editor), the tab opens wss://api.cinevva.com/game-creator/session/{gameId}/ws (authenticated by the Auth0 session cookie; must be game owner). It registers as role: executor and then dispatches incoming requests through the same client-side tool handlers it already has. Results return over the socket. It also pushes events (file_changed, console_error, game_ready) for v2 subscriptions.
3. MCP endpoint /create/mcp (new) — remote MCP server (Streamable HTTP), built on Cloudflare's agents SDK (McpAgent, itself DO-backed) + workers-oauth-provider. Per tool call: resolve userId from this.props (OAuth), resolve target gameId (see Session binding), get the GameSession stub, await stub.fetch('/exec', …), return the result as the MCP tool result. No gpt-5.4 involved.
4. OAuth (Auth0) — workers-oauth-provider fronts /create/mcp. Authorization delegates to Auth0; on consent we mint a token whose props carry { userId }. The MCP client (Cursor/Claude) performs the standard remote-MCP OAuth dance — no manual key paste.
Connection & auth flow
- User adds the MCP server URL
https://api.cinevva.com/create/mcpin Cursor/Claude. - Client hits the endpoint → 401 + OAuth metadata → opens Auth0 login → user consents.
workers-oauth-providerissues a bearer token withprops.userId.- Client opens the MCP session;
tools/listreturns the curated surface +resources/listreturns the user's live games. - User opens the game creator on the game they want (this is the strictly-live part) → the tab connects its executor WebSocket to
GameSession(gameId). - Agent calls a tool → worker →
GameSession(gameId).exec→ executor tab runs it → result streams back → user sees the iframe hot-reload.
Wire protocol (executor WebSocket)
JSON frames. Worker→tab requests, tab→worker results/events.
// worker → tab
{ "t": "req", "id": "r_8f3", "tool": "edit_files",
"args": { "edits": [{ "filename": "game.js", "find": "...", "replace": "..." }] } }
// tab → worker
{ "t": "res", "id": "r_8f3", "ok": true, "result": { /* tool result */ } }
{ "t": "res", "id": "r_8f3", "ok": false, "error": "file not found: game.js" }
// tab → worker (unsolicited, v2 push)
{ "t": "evt", "kind": "console_error", "data": { "message": "TypeError ..." } }
{ "t": "evt", "kind": "file_changed", "data": { "filename": "game.js" } }Timeouts: /exec rejects after ~30s (configurable per tool; generators are async — see below).
Tool surface (~12, curated)
The external client is the brain, so it gets building blocks, not UX tools. Reuse the schemas in shared/game-creator-tools.ts; drop ask_user, suggest_next, present_assets, create_new_game.
- File:
list_game_files,read_files,write_files,edit_files,delete_files,rename_files - Assets:
search_assets,import_asset,search_fonts - Generation (async, poll-based):
generate_image,generate_skybox,generate_music,generate_sfx - Live game:
execute_js,take_screenshot,read_console_logs,simulate_input - Control:
refresh_game(maps togame_ready)
Async generators don't fit one WS round-trip. The tool returns a jobId immediately; expose a get_job_status(jobId) tool the agent polls (mirrors the existing /genai/rig/status/{jobId} UI polling at GameCreator.vue:1708). Alternatively block server-side and stream MCP progress notifications — decide in build.
MCP resources
game://{gameId}/files/{path}— readable game files (so the agent can read without a tool call)game://{gameId}/console— rolling console buffergame://{gameId}/screenshot— latest layout snapshot
Session binding (which game?)
OAuth gives userId, not a game. Options, in order of preference:
- Auto-select the user's single live session if exactly one tab is connected (common case).
list_sessionstool / resources enumerate games that currently have a live executor; agent callsuse_game(gameId)to pin it for the connection.- Every tool accepts optional
gameId; defaults to the pinned/auto session.
Strictly-live rule: if the resolved gameId has no executor connected, tools return a structured error { code: "no_live_session", message: "Open this game in the Cinevva game creator to let the agent edit it." }.
Security & limits
- Executor WS auth: Auth0 session cookie +
game.user_id === userId(same gate ashandleGameCreatorChat,game-creator.ts:2703). Reject non-owners. - MCP caller
userId(from OAuth props) must equal theGameSession.userId. The DO enforces this on every/exec. - One executor per session; a second owner tab either takes over (kick old) or is rejected. Recommend take-over with a UI notice.
- Rate limit
/execper session (e.g. token bucket in the DO) to stop a runaway agent loop from hammering the tab. - Billing: external-agent generation still spends credits. Check balance in the generator tools (reuse existing gating,
game-creator.ts:2759), not per file edit. - All file mutations go through the existing IndexedDB → R2 sync path, so versioning and publish are unchanged.
Failure modes
- No tab open →
no_live_sessionerror (by design). - Tab closes mid-session → in-flight
/execreject; DO marks disconnected; subsequent calls error until a tab reconnects. - Two tabs → last writer wins on takeover; document it.
- Long generation → jobId + polling tool; never hold a WS frame open for minutes.
Phased build plan
Phase 1 — bridge core
GameSessionDO + binding inworker/wrangler.toml- Executor WS endpoint
/game-creator/session/{gameId}/ws+ owner auth GameCreator.vue: connect executor socket, dispatch existing tool handlers, "external agent" toggle + connection indicator- Smoke test with a raw WS client driving
list_game_files/edit_files
Phase 2 — MCP + OAuth
/create/mcpviaMcpAgent; curated tool defs delegating toGameSession.execworkers-oauth-providerin front, Auth0 as upstream IdP; props carryuserIdresources/listfor live games; auto-select +use_game- End-to-end from Cursor and Claude Desktop
Phase 3 — polish
- Async generators via jobId +
get_job_status - Push events / MCP progress notifications (
console_error,file_changed) - Rate limiting, takeover UX, telemetry
Open questions
- Async generators: poll vs. server-side block + MCP progress — pick in Phase 3.
- Do we want read-only "spectator" MCP connections (multiple agents observing one tab)?
- Surface the worker's gpt-5.4 loop later as an optional
send_messagetool, or keep the two paths fully separate? (Currently separate.)