clone

minesweeper as an mcp server: agents start games, play moves in batches and watch the board

Short replay share URLs with downloadable self-contained copies

Dami committed 6bdca26
- Games get a 10-hex slug (migration 0003); start_game and watch_game
  return share_url pointing at /r/{slug}
- /r/{slug} serves the spectator with the replay baked in, an expiry
  note (updated_at + 14d), and a download link; /r/{slug}.html mints
  the same page as an attachment with no expiry note and no link
- Spectator boots from the baked payload before fragment/preview/host
  modes; in-app share link now uses the short URL instead of eagerly
  encoding fragment links (old #replay= links still decode)
- Landing copy updated to describe share link + download
commit
6bdca26356a3413a5ffcdd25574a518e3f19c5d9
parent
f541b8508b76c78b6a308d58d658dca1b41e740d
author
Dami <dami.etoile@gmail.com>
8 files changed, +150 -6
Mmcp-app.html2+
Amigrations/0003_game_slugs.sql4++
Msrc/game.ts1+
Msrc/index.ts43+++++++++++++++++++-
Msrc/landing.ts2+-
Msrc/repository.ts24++++++++++-
Msrc/server.ts12++++-
Msrc/view.ts68++++++++++++++++++++++++++++++
M mcp-app.html +2 -0
A migrations/0003_game_slugs.sql +4 -0
M src/game.ts +1 -0
M src/index.ts +42 -1
@@ -1,10 +1,45 @@
11 import { createMcpHandler } from "@modelcontextprotocol/server";22 import viewHtml from "../dist/mcp-app.html";3+import { toInitialObservation } from "./game.ts";34 import { LANDING_CSP, LANDING_SCRIPT, landingPage } from "./landing.ts";4-import { deleteGamesInactiveBefore } from "./repository.ts";5+import { deleteGamesInactiveBefore, findGameBySlug, listAllEvents } from "./repository.ts";56 import { createServer } from "./server.ts";67 78 const GAME_RETENTION_MS = 14 * 24 * 60 * 60 * 1_000;9+const BAKED_REPLAY_SLOT = '"BAKED_REPLAY_SLOT"';10+11+const HTML_HEADERS = {12+ "Content-Type": "text/html; charset=utf-8",13+ "Cache-Control": "no-store",14+ "X-Content-Type-Options": "nosniff",15+ "Referrer-Policy": "no-referrer",16+};17+18+async function handleReplay(env: Env, slug: string, download: boolean): Promise<Response> {19+ const state = await findGameBySlug(env.DB, slug);20+ if (state === null) {21+ return new Response(22+ `<!doctype html><html lang="en"><head><meta charset="UTF-8" /><title>MCPSweeper</title></head><body><main><h1>Replay not found</h1><p>Server copies expire after 14 days of inactivity. A downloaded replay file keeps working forever.</p></main></body></html>`,23+ { status: 404, headers: HTML_HEADERS },24+ );25+ }26+ const events = await listAllEvents(env.DB, state.id);27+ const expiresAt = new Date(new Date(state.updatedAt).getTime() + GAME_RETENTION_MS).toISOString();28+ const payload = {29+ v: 1,30+ start: toInitialObservation(state),31+ events,32+ meta: download ? { kind: "file" } : { kind: "server", slug, expires_at: expiresAt },33+ };34+ const baked = JSON.stringify(payload).replaceAll("<", "\\u003c");35+ const html = viewHtml.replace(BAKED_REPLAY_SLOT, baked);36+ return new Response(html, {37+ headers: {38+ ...HTML_HEADERS,39+ ...(download ? { "Content-Disposition": `attachment; filename="mcpsweeper-replay-${slug}.html"` } : {}),40+ },41+ });42+}843 944 function jsonError(message: string, status: number): Response {1045 return Response.json({ error: message }, { status });
@@ -58,6 +93,12 @@ export default {
5893 },5994 });6095 }96+ if (request.method === "GET") {97+ const replayMatch = url.pathname.match(/^\/r\/([0-9a-f]{6,16})(\.html)?$/);98+ if (replayMatch !== null) {99+ return await handleReplay(env, replayMatch[1]!, replayMatch[2] === ".html");100+ }101+ }61102 if (request.method === "GET" && url.pathname === "/preview") {62103 return new Response(viewHtml, {63104 headers: {
M src/landing.ts +1 -1
M src/repository.ts +22 -2
@@ -23,6 +23,7 @@ type GameRow = {
2323 certified_no_guess: number;2424 created_at: string;2525 updated_at: string;26+ slug: string | null;2627 };2728 2829 type MoveRow = {
@@ -57,6 +58,7 @@ function fromRow(row: GameRow): GameState {
5758 certifiedNoGuess: row.certified_no_guess === 1,5859 createdAt: row.created_at,5960 updatedAt: row.updated_at,61+ ...(row.slug === null ? {} : { slug: row.slug }),6062 };6163 }6264
@@ -65,8 +67,8 @@ export async function insertGame(db: D1Database, state: GameState, mineCount: nu
6567 .prepare(6668 `INSERT INTO games (6769 id, seed, width, height, mine_count, mines, revealed, initial_revealed, flags, status,68- revision, exploded_index, certified_no_guess, created_at, updated_at69- ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)`,70+ revision, exploded_index, certified_no_guess, created_at, updated_at, slug71+ ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)`,7072 )7173 .bind(7274 state.id,
@@ -84,10 +86,28 @@ export async function insertGame(db: D1Database, state: GameState, mineCount: nu
8486 state.certifiedNoGuess ? 1 : 0,8587 state.createdAt,8688 state.updatedAt,89+ state.slug ?? null,8790 )8891 .run();8992 }9093 94+export async function findGameBySlug(db: D1Database, slug: string): Promise<GameState | null> {95+ const row = await db.prepare("SELECT * FROM games WHERE slug = ?1").bind(slug).first<GameRow>();96+ return row === null ? null : fromRow(row);97+}98+99+export async function listAllEvents(db: D1Database, gameId: string): Promise<MoveEvent[]> {100+ const events: MoveEvent[] = [];101+ let sinceRevision = 0;102+ while (true) {103+ const page = await listEvents(db, gameId, sinceRevision);104+ events.push(...page);105+ const last = page.at(-1);106+ if (last === undefined || page.length < 200) return events;107+ sinceRevision = last.revision;108+ }109+}110+91111 export async function getGame(db: D1Database, gameId: string): Promise<GameState | null> {92112 const row = await db.prepare("SELECT * FROM games WHERE id = ?1").bind(gameId).first<GameRow>();93113 return row === null ? null : fromRow(row);
M src/server.ts +10 -2
@@ -18,6 +18,11 @@ const APP_MIME_TYPE = "text/html;profile=mcp-app";
1818 1919 const gameIdSchema = z.string().uuid();2020 21+function generateSlug(): string {22+ const bytes = crypto.getRandomValues(new Uint8Array(5));23+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");24+}25+2126 function textResult(text: string, structuredContent: Record<string, unknown>) {2227 return {2328 content: [{ type: "text" as const, text }],
@@ -118,11 +123,13 @@ export function createServer(env: Env, viewHtml: string, previewUrl: string): Mc
118123 density,119124 maxAttempts: 500,120125 });126+ state.slug = generateSlug();121127 await insertGame(env.DB, state, countMines(state));122128 const position = toAgentPosition(state);129+ const shareUrl = new URL(`/r/${state.slug}`, previewUrl).toString();123130 return textResult(124- `Started ${size}x${size} no-guess MCPSweeper game ${gameId}. The spectator view is live. Solve the compact frontier constraints, then play cell IDs in batches until ${position.remaining_safe_cells} safe cells are cleared.`,125- { game_id: gameId, position, preview_url: previewUrl },131+ `Started ${size}x${size} no-guess MCPSweeper game ${gameId}. The spectator view is live and shareable at ${shareUrl}. Solve the compact frontier constraints, then play cell IDs in batches until ${position.remaining_safe_cells} safe cells are cleared.`,132+ { game_id: gameId, position, preview_url: previewUrl, share_url: shareUrl },126133 );127134 } catch (error) {128135 return errorResult(error);
@@ -232,6 +239,7 @@ export function createServer(env: Env, viewHtml: string, previewUrl: string): Mc
232239 observation,233240 events,234241 preview_url: previewUrl,242+ ...(state.slug === undefined ? {} : { share_url: new URL(`/r/${state.slug}`, previewUrl).toString() }),235243 ...(since_revision === 0 ? { initial_observation: toInitialObservation(state) } : {}),236244 });237245 },
M src/view.ts +68 -0
@@ -32,11 +32,18 @@ type MoveEvent = {
3232 type StructuredResult = {3333 game_id?: string;3434 preview_url?: unknown;35+ share_url?: unknown;3536 observation?: unknown;3637 initial_observation?: unknown;3738 events?: unknown[];3839 event?: unknown;3940 };41+42+type BakedReplay = {43+ start: Observation;44+ events: MoveEvent[];45+ meta: { slug?: string; expires_at?: string; kind?: string };46+};4047 type ReplayTurn = [4148 revision: number,4249 intent: string,
@@ -94,6 +101,7 @@ const state: {
94101 events: MoveEvent[];95102 latestIntent: string;96103 previewUrl: string;104+ shareUrl: string;97105 connection: Connection;98106 error: string;99107 displayRevision: number;
@@ -113,6 +121,7 @@ const state: {
113121 events: [],114122 latestIntent: "Waiting for the agent to begin…",115123 previewUrl: "",124+ shareUrl: "",116125 connection: window.top === window ? "preview" : "connecting",117126 error: "",118127 displayRevision: 0,
@@ -306,6 +315,50 @@ async function readReplayLink(): Promise<{ start: Observation; turns: MoveEvent[
306315 }307316 }308317 318+function readBakedReplay(): BakedReplay | null {319+ const element = document.getElementById("baked-replay");320+ if (element === null || element.textContent === null) return null;321+ try {322+ const data = JSON.parse(element.textContent) as unknown;323+ if (typeof data !== "object" || data === null) return null;324+ const payload = data as { v?: unknown; start?: unknown; events?: unknown; meta?: unknown };325+ if (payload.v !== 1 || !isVisualObservation(payload.start) || !Array.isArray(payload.events)) return null;326+ const events: MoveEvent[] = [];327+ for (const value of payload.events) {328+ const event = normalizeEvent(value);329+ if (event === null) return null;330+ events.push(event);331+ }332+ const meta = typeof payload.meta === "object" && payload.meta !== null ? (payload.meta as BakedReplay["meta"]) : {};333+ return { start: payload.start, events, meta };334+ } catch {335+ return null;336+ }337+}338+339+const expiryNote = requiredElement<HTMLParagraphElement>("#expiry-note");340+341+function applyBakedChrome(meta: BakedReplay["meta"]): void {342+ if (meta.kind === "file") {343+ replayLink.hidden = true;344+ return;345+ }346+ if (typeof meta.slug === "string") {347+ state.shareUrl = new URL(`/r/${meta.slug}`, location.href).toString();348+ replayLink.href = new URL(`/r/${meta.slug}.html`, location.href).toString();349+ replayLink.removeAttribute("target");350+ replayLink.textContent = "Download replay file";351+ replayLink.setAttribute("aria-disabled", "false");352+ }353+ if (typeof meta.expires_at === "string") {354+ const expires = new Date(meta.expires_at);355+ if (!Number.isNaN(expires.getTime())) {356+ expiryNote.hidden = false;357+ expiryNote.textContent = `Server copy expires ~${expires.toLocaleDateString(undefined, { month: "short", day: "numeric" })}`;358+ }359+ }360+}361+309362 function beginLinkedReplay(start: Observation, turns: MoveEvent[]): void {310363 state.initialObservation = start;311364 state.events = turns.sort((left, right) => left.revision - right.revision);
@@ -328,6 +381,7 @@ function consumeResult(value: unknown): void {
328381 const result = structured(value);329382 if (result === null) return;330383 if (typeof result.preview_url === "string") state.previewUrl = result.preview_url;384+ if (typeof result.share_url === "string") state.shareUrl = result.share_url;331385 if (result.game_id !== undefined && state.observation === null) {332386 state.latestIntent = `Field ${result.game_id.slice(0, 8)} initialized`;333387 }
@@ -820,6 +874,14 @@ function beginPreview(): void {
820874 let replayLinkKey = "";821875 let replayLinkGeneration = 0;822876 async function refreshReplayLink(): Promise<void> {877+ if (state.shareUrl !== "") {878+ if (replayLink.href !== state.shareUrl && state.connection !== "replay") {879+ replayLink.href = state.shareUrl;880+ replayLink.textContent = "Open share link";881+ replayLink.setAttribute("aria-disabled", "false");882+ }883+ return;884+ }823885 const start = state.initialObservation;824886 const live = state.observation;825887 if (start === null || live === null || state.previewUrl === "") {
@@ -991,6 +1053,12 @@ declare global {
9911053 }9921054 9931055 async function initialize(): Promise<void> {1056+ const baked = readBakedReplay();1057+ if (baked !== null) {1058+ beginLinkedReplay(baked.start, baked.events);1059+ applyBakedChrome(baked.meta);1060+ return;1061+ }9941062 const linkedReplay = await readReplayLink();9951063 if (linkedReplay !== null) {9961064 beginLinkedReplay(linkedReplay.start, linkedReplay.turns);