clone

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

main
mcpsweeper / src / server.ts
server.ts 8.9K
1import { McpServer } from "@modelcontextprotocol/server";2import { z } from "zod";3import {4 applyBatch,5 cellsToCoordinates,6 countMines,7 createNoGuessGame,8 toAgentPosition,9 toInitialObservation,10 toObservation,11 type BatchAction,12 type MoveEvent,13} from "./game.ts";14import { findMove, getGame, insertGame, listEvents, saveMove } from "./repository.ts";1516const APP_URI = "ui://mcpsweeper/spectator.html";17const APP_MIME_TYPE = "text/html;profile=mcp-app";1819const gameIdSchema = z.string().uuid();2021function generateSlug(): string {22 const bytes = crypto.getRandomValues(new Uint8Array(5));23 return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");24}2526function textResult(text: string, structuredContent: Record<string, unknown>) {27 return {28 content: [{ type: "text" as const, text }],29 structuredContent,30 };31}3233function errorResult(error: unknown) {34 const message = error instanceof Error ? error.message : "Unknown error";35 return {36 isError: true as const,37 content: [{ type: "text" as const, text: message }],38 };39}4041function sameAction(left: BatchAction, right: BatchAction): boolean {42 return JSON.stringify(left) === JSON.stringify(right);43}4445function moveSummary(event: MoveEvent, width: number) {46 return {47 revision: event.revision,48 submitted_count: event.submittedCount,49 revealed_count: event.revealed.length,50 flagged_count: event.flagged.length,51 unflagged_count: event.unflagged.length,52 status: event.status,53 ...(event.exploded === null ? {} : { exploded_cell: event.exploded.row * width + event.exploded.col }),54 };55}5657export function createServer(env: Env, viewHtml: string, previewUrl: string): McpServer {58 const server = new McpServer({ name: "mcpsweeper", version: "0.1.0" });5960 server.registerResource(61 "MCPSweeper spectator",62 APP_URI,63 {64 title: "MCPSweeper",65 description: "Read-only animated spectator view for an agent-played Minesweeper game.",66 mimeType: APP_MIME_TYPE,67 _meta: {68 ui: {69 prefersBorder: false,70 csp: {71 connectDomains: [],72 resourceDomains: [],73 },74 },75 },76 cacheHint: { ttlMs: 60_000, cacheScope: "public" },77 },78 async (uri) => ({79 contents: [80 {81 uri: uri.href,82 mimeType: APP_MIME_TYPE,83 text: viewHtml,84 _meta: {85 ui: {86 prefersBorder: false,87 csp: {88 connectDomains: [],89 resourceDomains: [],90 },91 },92 },93 },94 ],95 }),96 );9798 server.registerTool(99 "start_game",100 {101 title: "Start MCPSweeper",102 description:103 "Create a no-guess Minesweeper field and open its spectator view. Continue autonomously with observe_game and play_batch until the game is won or lost. Size may be any integer from 16 to 224; 64 is the default and 224 is the current empirically sub-second generation limit at default density.",104 inputSchema: z.object({105 size: z.number().int().min(16).max(224).default(64).describe("Square board width and height, from 16 to 224."),106 density: z.number().min(0.06).max(0.1).default(0.1),107 seed: z.string().min(1).max(80).optional(),108 }),109 _meta: {110 ui: { resourceUri: APP_URI, visibility: ["model", "app"] },111 "ui/resourceUri": APP_URI,112 },113 },114 async ({ size, density, seed }) => {115 try {116 const gameId = crypto.randomUUID();117 const gameSeed = seed ?? gameId;118 const state = createNoGuessGame({119 id: gameId,120 seed: gameSeed,121 width: size,122 height: size,123 density,124 maxAttempts: 500,125 });126 state.slug = generateSlug();127 await insertGame(env.DB, state, countMines(state));128 const position = toAgentPosition(state);129 const shareUrl = new URL(`/r/${state.slug}`, previewUrl).toString();130 return textResult(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 },133 );134 } catch (error) {135 return errorResult(error);136 }137 },138 );139140 server.registerTool(141 "observe_game",142 {143 title: "Observe MCPSweeper",144 description:145 "Read a compact constraint frontier without exposing hidden mines. Delta-decode frontier cells, clue cells, and flags using the included encoding notes; derive forced safe/mine cells from adjacent clues.",146 inputSchema: z.object({ game_id: gameIdSchema }),147 annotations: { readOnlyHint: true },148 },149 async ({ game_id }) => {150 const state = await getGame(env.DB, game_id);151 if (state === null) return errorResult(new Error("Game not found."));152 const position = toAgentPosition(state);153 return textResult(154 `Game ${game_id} is ${state.status} at revision ${state.revision}; ${position.remaining_safe_cells} safe cells remain.`,155 { position },156 );157 },158 );159160 server.registerTool(161 "play_batch",162 {163 title: "Play MCPSweeper batch",164 description:165 "Apply one batch using absolute integer cell IDs from the compact position. Flags are applied before reveals. Every reveal must be justified by the frontier; revealing a mine loses immediately. Use expected_revision for optimistic concurrency.",166 inputSchema: z.object({167 game_id: gameIdSchema,168 expected_revision: z.number().int().nonnegative(),169 reveal_cells: z.array(z.number().int().nonnegative()).max(8192).default([]),170 flag_cells: z.array(z.number().int().nonnegative()).max(8192).default([]),171 unflag_cells: z.array(z.number().int().nonnegative()).max(8192).default([]),172 intent: z.string().max(160).default(""),173 }),174 annotations: { destructiveHint: true, idempotentHint: true },175 },176 async ({ game_id, expected_revision, reveal_cells, flag_cells, unflag_cells, intent }) => {177 try {178 const state = await getGame(env.DB, game_id);179 if (state === null) return errorResult(new Error("Game not found."));180 const action: BatchAction = {181 reveal: cellsToCoordinates(state, reveal_cells),182 flag: cellsToCoordinates(state, flag_cells),183 unflag: cellsToCoordinates(state, unflag_cells),184 intent,185 };186187 if (state.revision !== expected_revision) {188 const prior = await findMove(env.DB, game_id, expected_revision + 1);189 if (prior !== null && sameAction(prior.action, action)) {190 return textResult("Duplicate batch replayed safely.", {191 move: moveSummary(prior.event, state.width),192 position: toAgentPosition(state),193 });194 }195 return errorResult(196 new Error(`Revision conflict: expected ${expected_revision}, current revision is ${state.revision}. Observe again.`),197 );198 }199200 const event = applyBatch(state, action);201 await saveMove(env.DB, expected_revision, state, action, event);202 const position = toAgentPosition(state);203 const summary =204 state.status === "won"205 ? `Solved MCPSweeper in ${state.revision} batches.`206 : state.status === "lost"207 ? "A mine detonated. The game is lost."208 : `Batch ${state.revision} accepted: ${event.revealed.length} revealed, ${event.flagged.length} flagged; ${position.remaining_safe_cells} safe cells remain.`;209 return textResult(summary, { move: moveSummary(event, state.width), position });210 } catch (error) {211 return errorResult(error);212 }213 },214 );215216 server.registerTool(217 "watch_game",218 {219 title: "Watch MCPSweeper",220 description: "Read-only polling endpoint used by the spectator App.",221 inputSchema: z.object({222 game_id: gameIdSchema,223 since_revision: z.number().int().nonnegative().default(0),224 }),225 annotations: { readOnlyHint: true },226 _meta: {227 ui: { resourceUri: APP_URI, visibility: ["app"] },228 "ui/resourceUri": APP_URI,229 },230 },231 async ({ game_id, since_revision }) => {232 const [state, events] = await Promise.all([233 getGame(env.DB, game_id),234 listEvents(env.DB, game_id, since_revision),235 ]);236 if (state === null) return errorResult(new Error("Game not found."));237 const observation = toObservation(state, state.status === "lost");238 return textResult(`Spectator snapshot at revision ${state.revision}.`, {239 observation,240 events,241 preview_url: previewUrl,242 ...(state.slug === undefined ? {} : { share_url: new URL(`/r/${state.slug}`, previewUrl).toString() }),243 ...(since_revision === 0 ? { initial_observation: toInitialObservation(state) } : {}),244 });245 },246 );247248 return server;249}