Skip to content

Game Creator MCP Server — Design

Status: implemented (pending deploy) · Author: design session 2026-06-17

What shipped

Worker:

  • worker/src/game-session-do.tsGameSessionDO Durable Object (executor WS + /exec relay + 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 with SESSION_SECRET.
  • worker/src/mcp/tools.ts — curated tool surface; schemas reused verbatim from shared/game-creator-tools.ts, plus the MCP-only list_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 — Env GAME_SESSION binding, DO export, routes (/.well-known/oauth-*, /mcp/oauth/*, /create/mcp, /game-creator/session/:gameId/ws).
  • worker/wrangler.tomlGAME_SESSION DO binding + migration v7.

Frontend (.vitepress/theme/game-creator/GameCreator.vue):

  • Executor WebSocket adapter: receives req frames, runs them through the existing executeLocalTool (same path as in-app chat), returns res frames; 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

  1. cd worker && npx wrangler deploy (creates the GameSessionDO migration v7).
  2. No new secrets needed — tokens are signed with the existing SESSION_SECRET; login reuses the existing Auth0 session.
  3. In Cursor / Claude, add a remote MCP server: https://api.cinevva.com/create/mcp. The client runs OAuth (DCR → Auth0-backed consent → token) automatically.
  4. Open a game in the Cinevva game creator and toggle External agent on (toolbar ⋯ menu). The tab connects as executor.
  5. 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 pushes console evt frames; read_console_logs tool already works live in the meantime.
  • Async generators (generate_*) return whatever executeLocalTool returns synchronously; if any kick off background jobs, add a get_job_status tool.
  • Cookie/domain: /mcp/oauth/authorize reads the session cookie on api.cinevva.com. If login lives only on the app domain, confirm the cookie is scoped to .cinevva.com or wire the returnTo login 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 files

Components

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 it
  • GET /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

  1. User adds the MCP server URL https://api.cinevva.com/create/mcp in Cursor/Claude.
  2. Client hits the endpoint → 401 + OAuth metadata → opens Auth0 login → user consents.
  3. workers-oauth-provider issues a bearer token with props.userId.
  4. Client opens the MCP session; tools/list returns the curated surface + resources/list returns the user's live games.
  5. 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).
  6. 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.

jsonc
// 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 to game_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 buffer
  • game://{gameId}/screenshot — latest layout snapshot

Session binding (which game?)

OAuth gives userId, not a game. Options, in order of preference:

  1. Auto-select the user's single live session if exactly one tab is connected (common case).
  2. list_sessions tool / resources enumerate games that currently have a live executor; agent calls use_game(gameId) to pin it for the connection.
  3. 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 as handleGameCreatorChat, game-creator.ts:2703). Reject non-owners.
  • MCP caller userId (from OAuth props) must equal the GameSession.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 /exec per 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 openno_live_session error (by design).
  • Tab closes mid-session → in-flight /exec reject; 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

  • GameSession DO + binding in worker/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/mcp via McpAgent; curated tool defs delegating to GameSession.exec
  • workers-oauth-provider in front, Auth0 as upstream IdP; props carry userId
  • resources/list for 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_message tool, or keep the two paths fully separate? (Currently separate.)