Mafia (Werewolf) as a remote MCP server for up to 80 players in one room, each connected through their own ChatGPT or Claude. Stateless on Cloud Run — any replica serves any request; killing an instance mid-game is a demo beat, not an outage.
Status: feature-complete. Core loop, MCP App UI (role card, town board,
victory screen), elicitation with cross-replica answer relay, projector
board, OAuth identity, staging drills (80-seat burst, soak, kill-instance)
— all verified against the live deployment and real clients. DECISIONS.md
records every divergence and judgment call; DEMO_RUNBOOK.md runs the show.
src/game/ pure rules: reducer, roles, narration, viewFor (redaction)
src/store/ RoomStore interface; memory + Firestore implementations
src/server/ MCP tools, identity, stateless streamable-HTTP wiring (Hono)
src/bots/ scripted MCP clients: synthetic audience + smoke driver
test/ unit, redaction, store contract, e2e-over-HTTP, fuzz
infra/ Terraform: Cloud Run, AR, Secret Manager, WIF for CI, TTLs
Architecture rules that everything else obeys:
- Zero server-side session state. Fresh
McpServer+ transport per request, no session ids. All state in the room store. viewFor(state, playerId)is the only exit for game state. Redaction bugs are P0;test/redaction.test.tshunts for them.- The model narrates; the server decides. Tool results carry
next_step_hintand server-generated narration; models are instructed never to invent facts. - Every write is validated in a transaction against the current phase, role, and liveness. One action per player per phase, last write wins.
npm ci
npm test # unit + redaction + store contract + e2e + 2k-game fuzz
npm run fuzz # the full 10,000-game invariant sweep (~2.5 min)
# run the server on the in-memory store
MAFIA_STORE=memory PORT=8080 npx tsx src/server/main.ts
# ...or against the Firestore emulator (Docker):
npm run emulator & # port 8899
FIRESTORE_EMULATOR_HOST=localhost:8899 npx tsx src/server/main.ts
# a complete self-driving game (bot moderator + N bots):
npx tsx src/bots/audience.ts --url http://localhost:8080/mcp --smoke 12
# 77 bots joining your room as the audience:
npx tsx src/bots/audience.ts --url http://localhost:8080/mcp --join PLUM --bots 77Firestore emulator tests only run when the emulator env var is set:
FIRESTORE_EMULATOR_HOST=localhost:8899 npx vitest run test/store.firestore.test.tsPushes to main run .github/workflows/deploy.yml: build image → push to
Artifact Registry → terraform apply → smoke game against the live service.
CI authenticates via Workload Identity Federation (no SA keys). Repo
variables required: GCP_WORKLOAD_IDENTITY_PROVIDER, GCP_DEPLOYER_SA
(values are Terraform outputs).
One-time bootstrap from a laptop (already done for staging):
gcloud storage buckets create gs://virtual-library-mcp-tfstate --location=us-central1 --uniform-bucket-level-access
cd infra && terraform init
terraform apply -target=google_project_service.apis -target=google_artifact_registry_repository.mafia -var image=dummy
docker build --platform linux/amd64 -t <repo>/server:bootstrap . && docker push <repo>/server:bootstrap
terraform apply -var image=<repo>/server:bootstrapStaging: https://mafia-staging-1022738355193.us-central1.run.app/mcp
(health: /health — note Cloud Run's frontend swallows /healthz).
MCP Inspector
npx @modelcontextprotocol/inspectorTransport: Streamable HTTP → URL <server>/mcp. Checklist:
-
initializeshows servermafiawith instructions -
tools/listshows exactly 10 tools;get_state/how_to_playare annotated read-only -
create_roomreturns a code +player_tokeninstructuredContent - Paste the token into
player_tokenonstart_game→ needs 5 players (error should read like a sentence a player understands) -
join_roomwith no arguments from a second Inspector tab finds the featured room - Dead-player and wrong-phase actions come back as teaching errors
ChatGPT (developer mode) — Settings → Apps & Connectors → Advanced →
Developer mode (Business/Enterprise/Edu for write tools) → Create app →
URL <server>/mcp, auth: none. Then in a chat, enable the connector and say
"take me to the mafia game."
Claude (custom connector) — Settings → Connectors → Add custom connector →
URL <server>/mcp, no auth. Works on claude.ai web and mobile.
GET /room/{code}/board — a self-contained big-screen page (SSE, public
view only): live lobby, narration, vote tally bars, victory splash. This is
the room's shared anchor; put it on the projector.
submit_night_action without a target raises a private picker via MCP
elicitation (enum of legal targets). Clients without elicitation (Claude
custom connectors today) get a teaching error pointing at the app's tap
path — two paths, one validated write. Closing a vote that would banish
nobody (tie / no votes) asks the moderator accept/decline the same way.
Answers that land on the wrong replica are relayed through the store —
elicitation survives multi-instance round-robin (tested).
- Moderator: "create a mafia room" → share the 4-letter code out loud.
- Everyone: "join the mafia game as " (no code needed — featured room).
- Moderator:
start_game, thenadvance_phaseto pace night → dawn → discussion → vote → dusk → night... - Night roles act via
submit_night_action; day votes viacast_vote;get_staterefreshes; every result says what to do next.
Resolution order per request:
- OAuth subject —
Authorization: Bearer <JWT>verified againstMAFIA_OIDC_ISSUER/MAFIA_OIDC_JWKS_URL/MAFIA_OIDC_AUDIENCE. Seats bind to the stable subject: reconnects are automatic and names can't be stolen. (This is the resource-server half; the authorization server / connector OAuth flow is configured against the workspace IdP — see DECISIONS.md #35.) - Player token —
create_room/join_roommint an HMAC-signedplayer_token; passed as the tool argument (interactive clients) or asAuthorization: Bearer(bots, Inspector, load tests). - Lost token, no SSO:
join_roomwith the same name reclaims the seat (anonymous seats only — subject-bound seats never reclaim by name).
npx tsx src/bots/drill.ts --url <server>/mcp burst # 80 votes ≤5s, zero lost
npx tsx src/bots/drill.ts --url <server>/mcp soak # 60s autopoll churn, zero errors
npx tsx src/bots/drill.ts --url <server>/mcp killgame # full game across a revision roll
# mid-killgame, replace every instance:
gcloud run services update mafia-staging --region us-central1 --update-env-vars DRILL_TS=$(date +%s)ChatGPT (Enterprise workspace) — keep the connector in developer-mode
drafts while iterating (published apps freeze a tool snapshot only admins
refresh). To publish: workspace admin → Settings → Apps → create from the
/mcp URL → Scan tools → Create → Publish, then "Configure Access" to the
audience group. Tool schemas are frozen as of v0.3.0 precisely so this scan
never needs a refresh mid-conference.
Claude (custom connector) — Settings → Connectors → Add custom
connector → <server>/mcp, no auth. Works on web, Desktop, iOS/Android
(apps render on mobile). The board resource carries the Claude sandbox
ui.domain derived from MAFIA_PUBLIC_URL — set that env var (Terraform)
if the service URL ever changes.