clone

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

main
mcpsweeper / src / landing.ts
landing.ts 21.8K
1function escapeHtml(value: string): string {2 return value3 .replaceAll("&", "&amp;")4 .replaceAll("<", "&lt;")5 .replaceAll(">", "&gt;")6 .replaceAll('"', "&quot;")7 .replaceAll("'", "&#039;");8}910export const LANDING_CSP =11 "default-src 'none'; script-src 'self'; connect-src 'self'; img-src data:; style-src 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; base-uri 'none'; form-action 'self'; frame-ancestors 'none'";1213// Served at /webmcp.js. Registers this page's WebMCP tools (imperative API) and14// wires the declarative start_game form; both proxy JSON-RPC to the same-origin15// /mcp endpoint, which replies with SSE-framed or plain JSON bodies.16export const LANDING_SCRIPT = `(() => {17 const MCP = new URL("/mcp", location.href).toString();18 let nextId = 1;1920 async function rpc(method, params) {21 const response = await fetch(MCP, {22 method: "POST",23 headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream" },24 body: JSON.stringify({ jsonrpc: "2.0", id: nextId++, method, params }),25 });26 let payload = await response.text();27 if (payload.startsWith("event:")) {28 const data = payload.split("\\n").find((line) => line.startsWith("data:"));29 payload = data ? data.slice(5) : "{}";30 }31 const message = JSON.parse(payload);32 if (message.error) throw new Error(message.error.message || "MCP request failed");33 return message.result;34 }3536 async function callTool(name, args) {37 try {38 const result = await rpc("tools/call", { name, arguments: args });39 return {40 content: result.content || [],41 ...(result.structuredContent ? { structuredContent: result.structuredContent } : {}),42 ...(result.isError ? { isError: true } : {}),43 };44 } catch (error) {45 return { content: [{ type: "text", text: String(error.message || error) }], isError: true };46 }47 }4849 const form = document.getElementById("start-form");50 const output = document.getElementById("start-output");51 form.addEventListener("submit", (event) => {52 event.preventDefault();53 const size = Math.min(224, Math.max(16, Number(new FormData(form).get("size")) || 20));54 const pending = callTool("start_game", { size }).then((result) => {55 output.hidden = false;56 output.textContent = result.content[0] ? result.content[0].text : "No response.";57 return result;58 });59 if (typeof event.respondWith === "function") event.respondWith(pending);60 });6162 const modelContext = navigator.modelContext || document.modelContext;63 if (!modelContext || typeof modelContext.registerTool !== "function") return;6465 modelContext.registerTool({66 name: "observe_game",67 description:68 "Read a compact constraint frontier for an MCPSweeper game 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.",69 inputSchema: {70 type: "object",71 properties: {72 game_id: { type: "string", description: "The game_id returned by start_game." },73 },74 required: ["game_id"],75 },76 async execute({ game_id }) {77 return callTool("observe_game", { game_id });78 },79 });8081 modelContext.registerTool({82 name: "play_batch",83 description:84 "Apply one MCPSweeper 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.",85 inputSchema: {86 type: "object",87 properties: {88 game_id: { type: "string", description: "The game_id returned by start_game." },89 expected_revision: { type: "integer", description: "Current revision from the last observation." },90 reveal_cells: { type: "array", items: { type: "integer" }, description: "Cell IDs to reveal." },91 flag_cells: { type: "array", items: { type: "integer" }, description: "Cell IDs to flag." },92 unflag_cells: { type: "array", items: { type: "integer" }, description: "Cell IDs to unflag." },93 intent: { type: "string", description: "Short note shown in the spectator's batch history." },94 },95 required: ["game_id", "expected_revision"],96 },97 async execute(args) {98 return callTool("play_batch", args);99 },100 });101})();102`;103104export function landingPage(request: Request): string {105 const mcpUrl = new URL("/mcp", request.url).toString();106107 return `<!doctype html>108<html lang="en">109<head>110<meta charset="UTF-8" />111<meta name="viewport" content="width=device-width, initial-scale=1.0" />112<title>MCPSweeper</title>113<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 26 26'%3E%3Crect width='26' height='26' fill='%23ffffff'/%3E%3Crect x='2' y='2' width='24' height='24' fill='%23808080'/%3E%3Crect x='2' y='2' width='22' height='22' fill='%23c0c0c0'/%3E%3Ccircle cx='13' cy='13' r='8.5' fill='%23ffff00' stroke='%23000000' stroke-width='1.3'/%3E%3Ccircle cx='10.2' cy='10.8' r='1.3' fill='%23000000'/%3E%3Ccircle cx='15.8' cy='10.8' r='1.3' fill='%23000000'/%3E%3Cpath d='M9.2 15.6q3.8 3.4 7.6 0' fill='none' stroke='%23000000' stroke-width='1.3' stroke-linecap='round'/%3E%3C/svg%3E" />114<link rel="preconnect" href="https://fonts.googleapis.com" />115<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />116<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,400;0,500;0,600;1,400&family=IBM+Plex+Serif:ital,wght@0,400;0,500;0,600;1,400&display=swap" rel="stylesheet" />117<script src="/webmcp.js" defer></script>118<style>119 :root {120 --paper: #faf9f6;121 --ink: #1c1b18;122 --muted: #6b6961;123 --rule: #dcdad2;124 --accent: #00007a;125 --w98-face: #c0c0c0;126 --w98-light: #ffffff;127 --w98-shadow: #808080;128 --w98-dark: #404040;129 }130131 * { box-sizing: border-box; }132133 html { background: var(--paper); }134135 body {136 margin: 0;137 color: var(--ink);138 font-family: "IBM Plex Serif", Georgia, serif;139 font-size: 17px;140 line-height: 1.65;141 -webkit-font-smoothing: antialiased;142 }143144 main {145 max-width: 620px;146 margin: 0 auto;147 padding: 72px 24px 96px;148 }149150 header { margin-bottom: 40px; }151152 h1 {153 font-family: "IBM Plex Mono", monospace;154 font-weight: 600;155 font-size: 34px;156 letter-spacing: -0.01em;157 margin: 0 0 14px;158 }159160 h1 .smiley {161 width: 0.78em; height: 0.78em;162 margin-left: 0.28em;163 vertical-align: -0.06em;164 }165166 .lede {167 font-size: 19px;168 margin: 0;169 }170171 h2 {172 font-family: "IBM Plex Mono", monospace;173 font-weight: 500;174 font-size: 13px;175 letter-spacing: 0.12em;176 text-transform: uppercase;177 color: var(--muted);178 margin: 52px 0 14px;179 padding-top: 20px;180 border-top: 1px solid var(--rule);181 }182183 p { margin: 0 0 1em; }184 p:last-child { margin-bottom: 0; }185186 code {187 font-family: "IBM Plex Mono", monospace;188 font-size: 0.85em;189 background: #f0eee8;190 border: 1px solid var(--rule);191 border-radius: 2px;192 padding: 0.08em 0.35em;193 white-space: nowrap;194 }195196 a { color: var(--accent); text-underline-offset: 2px; }197198 .prompt-label {199 font-family: "IBM Plex Mono", monospace;200 font-size: 13px;201 font-weight: 500;202 margin: 28px 0 10px;203 }204205 .prompt {206 background: var(--w98-face);207 border-top: 3px solid var(--w98-shadow);208 border-left: 3px solid var(--w98-shadow);209 border-right: 3px solid var(--w98-light);210 border-bottom: 3px solid var(--w98-light);211 box-shadow: inset 1px 1px 0 var(--w98-dark);212 padding: 3px;213 margin: 0 0 10px;214 }215216 .prompt blockquote {217 margin: 0;218 background: #ffffff;219 padding: 14px 16px;220 font-family: "IBM Plex Mono", monospace;221 font-size: 13.5px;222 line-height: 1.6;223 user-select: all;224 -webkit-user-select: all;225 cursor: text;226 }227228 .prompt blockquote code {229 background: none; border: none; padding: 0; font-size: inherit;230 color: var(--accent);231 white-space: normal;232 overflow-wrap: anywhere;233 }234235 .prompt-hint {236 font-family: "IBM Plex Mono", monospace;237 font-size: 12px;238 color: var(--muted);239 margin: 0;240 }241242 figure.replay {243 margin: 40px 0 0;244 }245246 .replay-loop {247 font-family: "IBM Plex Mono", monospace;248 font-size: 12.5px;249 color: var(--muted);250 margin: 0 0 16px;251 }252253 .replay-loop code {254 background: none; border: none; padding: 0;255 color: var(--ink);256 font-size: inherit;257 }258259 .strip {260 display: flex;261 align-items: flex-start;262 gap: 10px;263 }264265 .snap { flex: 1; min-width: 0; margin: 0; }266267 .board {268 display: grid;269 grid-template-columns: repeat(9, minmax(0, 1fr));270 gap: 1px;271 background: var(--w98-shadow);272 border-top: 3px solid var(--w98-shadow);273 border-left: 3px solid var(--w98-shadow);274 border-right: 3px solid var(--w98-light);275 border-bottom: 3px solid var(--w98-light);276 }277278 .board i {279 aspect-ratio: 1;280 min-width: 0;281 overflow: hidden;282 display: grid;283 place-items: center;284 font-family: "IBM Plex Mono", monospace;285 font-style: normal;286 font-weight: 600;287 font-size: 11px;288 background: var(--w98-face);289 }290291 .board .u {292 box-shadow: inset 2px 2px 0 var(--w98-light), inset -2px -2px 0 var(--w98-shadow);293 }294295 .board .r { background: #cbc9c1; }296297 .board .f {298 box-shadow: inset 2px 2px 0 var(--w98-light), inset -2px -2px 0 var(--w98-shadow);299 }300301 .board .f::before { content: "\\2691"; color: #c00000; font-size: 10px; }302303 .board .n1 { color: #0000c8; }304 .board .n2 { color: #007000; }305 .board .n3 { color: #c00000; }306 .board .n4 { color: #000078; }307308 .snap figcaption {309 font-family: "IBM Plex Mono", monospace;310 font-size: 11.5px;311 line-height: 1.5;312 color: var(--muted);313 margin-top: 8px;314 }315316 .snap figcaption b {317 display: block;318 font-weight: 600;319 color: var(--ink);320 }321322 .strip .arrow {323 align-self: center;324 font-family: "IBM Plex Mono", monospace;325 color: var(--muted);326 flex: none;327 padding-bottom: 32px;328 }329330 @media (max-width: 480px) {331 .strip { gap: 6px; }332 .strip .arrow { display: none; }333 .board i { font-size: 8px; }334 .board .f::before { font-size: 7px; }335 }336337 form.start {338 display: flex;339 align-items: center;340 gap: 10px;341 font-family: "IBM Plex Mono", monospace;342 font-size: 13.5px;343 margin: 18px 0 0;344 }345346 form.start label { color: var(--muted); }347348 form.start input {349 font-family: "IBM Plex Mono", monospace;350 font-size: 13.5px;351 width: 5.5em;352 padding: 5px 8px;353 background: #ffffff;354 color: var(--ink);355 border-top: 2px solid var(--w98-shadow);356 border-left: 2px solid var(--w98-shadow);357 border-right: 2px solid var(--w98-light);358 border-bottom: 2px solid var(--w98-light);359 box-shadow: inset 1px 1px 0 var(--w98-dark);360 border-radius: 0;361 }362363 form.start button {364 font-family: "IBM Plex Mono", monospace;365 font-size: 13.5px;366 font-weight: 500;367 padding: 5px 16px;368 color: var(--ink);369 background: var(--w98-face);370 border-top: 2px solid var(--w98-light);371 border-left: 2px solid var(--w98-light);372 border-right: 2px solid var(--w98-dark);373 border-bottom: 2px solid var(--w98-dark);374 border-radius: 0;375 cursor: pointer;376 }377378 form.start button:active {379 border-top: 2px solid var(--w98-dark);380 border-left: 2px solid var(--w98-dark);381 border-right: 2px solid var(--w98-light);382 border-bottom: 2px solid var(--w98-light);383 }384385 #start-output {386 font-family: "IBM Plex Mono", monospace;387 font-size: 12.5px;388 line-height: 1.6;389 white-space: pre-wrap;390 overflow-wrap: anywhere;391 background: #f0eee8;392 border: 1px solid var(--rule);393 padding: 10px 12px;394 margin: 12px 0 0;395 }396397 dl.tools { margin: 0; }398399 dl.tools dt {400 font-family: "IBM Plex Mono", monospace;401 font-size: 14px;402 font-weight: 600;403 margin-top: 14px;404 }405406 dl.tools dt:first-child { margin-top: 0; }407408 dl.tools dd {409 margin: 2px 0 0 0;410 }411412 ul.details {413 margin: 0; padding: 0;414 list-style: none;415 }416417 ul.details li {418 padding-left: 1.4em;419 position: relative;420 margin-bottom: 0.7em;421 }422423 ul.details li::before {424 content: "";425 position: absolute;426 left: 0; top: 0.48em;427 width: 9px; height: 9px;428 background: var(--w98-face);429 border-top: 2px solid var(--w98-light);430 border-left: 2px solid var(--w98-light);431 border-right: 2px solid var(--w98-shadow);432 border-bottom: 2px solid var(--w98-shadow);433 }434435 footer {436 margin-top: 64px;437 padding-top: 20px;438 border-top: 1px solid var(--rule);439 font-family: "IBM Plex Mono", monospace;440 font-size: 12px;441 color: var(--muted);442 }443444 @media (max-width: 480px) {445 body { font-size: 16px; }446 main { padding-top: 48px; }447 h1 { font-size: 28px; }448 }449</style>450</head>451<body>452<main>453 <header>454 <h1>MCPSweeper<svg class="smiley" viewBox="0 0 26 26" aria-hidden="true"><rect width="26" height="26" fill="#ffffff"/><rect x="2" y="2" width="24" height="24" fill="#808080"/><rect x="2" y="2" width="22" height="22" fill="#c0c0c0"/><circle cx="13" cy="13" r="8.5" fill="#ffff00" stroke="#000000" stroke-width="1.3"/><circle cx="10.2" cy="10.8" r="1.3" fill="#000000"/><circle cx="15.8" cy="10.8" r="1.3" fill="#000000"/><path d="M9.2 15.6q3.8 3.4 7.6 0" fill="none" stroke="#000000" stroke-width="1.3" stroke-linecap="round"/></svg></h1>455 <p class="lede">A no-guess Minesweeper server for AI agents, built as a working example of the July 2026 Model Context Protocol specification and MCP Apps. Point an agent at it and watch it solve a board live.</p>456 </header>457458 <p class="prompt-label">Try it — paste this into your agent:</p>459 <div class="prompt">460 <blockquote>Install the MCPSweeper MCP server at <code>${escapeHtml(mcpUrl)}</code>, start a 20×20 game, and play autonomously until you win.</blockquote>461 </div>462 <p class="prompt-hint">click to select the whole prompt</p>463464 <figure class="replay">465 <p class="replay-loop">The whole interaction is one loop: <code>observe_game</code>, deduce the forced cells, <code>play_batch</code>, repeat until the board is clear.</p>466 <div class="strip">467 <figure class="snap">468 <div class="board" aria-label="Board after start_game: a 30-cell opening in the top-left corner">469<i class="r"></i><i class="r"></i><i class="r"></i><i class="r"></i><i class="r n1">1</i><i class="u"></i><i class="u"></i><i class="u"></i><i class="u"></i>470<i class="r"></i><i class="r"></i><i class="r"></i><i class="r"></i><i class="r n1">1</i><i class="u"></i><i class="u"></i><i class="u"></i><i class="u"></i>471<i class="r"></i><i class="r"></i><i class="r"></i><i class="r n1">1</i><i class="r n2">2</i><i class="u"></i><i class="u"></i><i class="u"></i><i class="u"></i>472<i class="r"></i><i class="r n1">1</i><i class="r n1">1</i><i class="r n2">2</i><i class="u"></i><i class="u"></i><i class="u"></i><i class="u"></i><i class="u"></i>473<i class="r"></i><i class="r n1">1</i><i class="u"></i><i class="u"></i><i class="u"></i><i class="u"></i><i class="u"></i><i class="u"></i><i class="u"></i>474<i class="r"></i><i class="r n1">1</i><i class="r n1">1</i><i class="u"></i><i class="u"></i><i class="u"></i><i class="u"></i><i class="u"></i><i class="u"></i>475<i class="r"></i><i class="r"></i><i class="r n1">1</i><i class="u"></i><i class="u"></i><i class="u"></i><i class="u"></i><i class="u"></i><i class="u"></i>476<i class="r n1">1</i><i class="r n1">1</i><i class="r n1">1</i><i class="u"></i><i class="u"></i><i class="u"></i><i class="u"></i><i class="u"></i><i class="u"></i>477<i class="u"></i><i class="u"></i><i class="u"></i><i class="u"></i><i class="u"></i><i class="u"></i><i class="u"></i><i class="u"></i><i class="u"></i>478 </div>479 <figcaption><b>start_game</b>30 cells open for free</figcaption>480 </figure>481 <span class="arrow" aria-hidden="true">→</span>482 <figure class="snap">483 <div class="board" aria-label="Board after a play_batch: 27 more cells revealed and 9 mines flagged">484<i class="r"></i><i class="r"></i><i class="r"></i><i class="r"></i><i class="r n1">1</i><i class="r n1">1</i><i class="r n2">2</i><i class="r n2">2</i><i class="f"></i>485<i class="r"></i><i class="r"></i><i class="r"></i><i class="r"></i><i class="r n1">1</i><i class="f"></i><i class="r n3">3</i><i class="f"></i><i class="r n2">2</i>486<i class="r"></i><i class="r"></i><i class="r"></i><i class="r n1">1</i><i class="r n2">2</i><i class="r n4">4</i><i class="f"></i><i class="r n3">3</i><i class="r n1">1</i>487<i class="r"></i><i class="r n1">1</i><i class="r n1">1</i><i class="r n2">2</i><i class="f"></i><i class="r n3">3</i><i class="f"></i><i class="r n2">2</i><i class="r"></i>488<i class="r"></i><i class="r n1">1</i><i class="f"></i><i class="r n2">2</i><i class="r n1">1</i><i class="r n2">2</i><i class="u"></i><i class="u"></i><i class="u"></i>489<i class="r"></i><i class="r n1">1</i><i class="r n1">1</i><i class="r n1">1</i><i class="r"></i><i class="r"></i><i class="u"></i><i class="u"></i><i class="u"></i>490<i class="r"></i><i class="r"></i><i class="r n1">1</i><i class="r n1">1</i><i class="r n1">1</i><i class="r"></i><i class="u"></i><i class="u"></i><i class="u"></i>491<i class="r n1">1</i><i class="r n1">1</i><i class="r n1">1</i><i class="f"></i><i class="r n1">1</i><i class="r"></i><i class="u"></i><i class="u"></i><i class="u"></i>492<i class="f"></i><i class="r n1">1</i><i class="r n1">1</i><i class="r n1">1</i><i class="r n1">1</i><i class="r"></i><i class="u"></i><i class="u"></i><i class="u"></i>493 </div>494 <figcaption><b>play_batch</b>27 reveals, 9 flags, all forced by clues</figcaption>495 </figure>496 <span class="arrow" aria-hidden="true">→</span>497 <figure class="snap">498 <div class="board" aria-label="Finished board: every safe cell revealed, all 10 mines flagged">499<i class="r"></i><i class="r"></i><i class="r"></i><i class="r"></i><i class="r n1">1</i><i class="r n1">1</i><i class="r n2">2</i><i class="r n2">2</i><i class="f"></i>500<i class="r"></i><i class="r"></i><i class="r"></i><i class="r"></i><i class="r n1">1</i><i class="f"></i><i class="r n3">3</i><i class="f"></i><i class="r n2">2</i>501<i class="r"></i><i class="r"></i><i class="r"></i><i class="r n1">1</i><i class="r n2">2</i><i class="r n4">4</i><i class="f"></i><i class="r n3">3</i><i class="r n1">1</i>502<i class="r"></i><i class="r n1">1</i><i class="r n1">1</i><i class="r n2">2</i><i class="f"></i><i class="r n3">3</i><i class="f"></i><i class="r n2">2</i><i class="r"></i>503<i class="r"></i><i class="r n1">1</i><i class="f"></i><i class="r n2">2</i><i class="r n1">1</i><i class="r n2">2</i><i class="r n2">2</i><i class="r n2">2</i><i class="r n1">1</i>504<i class="r"></i><i class="r n1">1</i><i class="r n1">1</i><i class="r n1">1</i><i class="r"></i><i class="r"></i><i class="r n1">1</i><i class="f"></i><i class="r n1">1</i>505<i class="r"></i><i class="r"></i><i class="r n1">1</i><i class="r n1">1</i><i class="r n1">1</i><i class="r"></i><i class="r n1">1</i><i class="r n1">1</i><i class="r n1">1</i>506<i class="r n1">1</i><i class="r n1">1</i><i class="r n1">1</i><i class="f"></i><i class="r n1">1</i><i class="r"></i><i class="r"></i><i class="r"></i><i class="r"></i>507<i class="f"></i><i class="r n1">1</i><i class="r n1">1</i><i class="r n1">1</i><i class="r n1">1</i><i class="r"></i><i class="r"></i><i class="r"></i><i class="r"></i>508 </div>509 <figcaption><b>won</b>every safe cell, zero guesses</figcaption>510 </figure>511 </div>512 </figure>513514 <section>515 <h2>Play from this page</h2>516 <p>This page is also a WebMCP host. In a browser that supports <code>navigator.modelContext</code>, the form below is a declarative <code>start_game</code> tool and <code>observe_game</code> / <code>play_batch</code> are registered imperatively, so a browser agent can play with no install at all. The form works for humans too.</p>517 <form518 class="start"519 id="start-form"520 toolname="start_game"521 tooldescription="Create a no-guess MCPSweeper board on this site. Returns the game_id and starting position; continue with observe_game and play_batch until every safe cell is revealed."522 toolautosubmit>523 <label for="start-size">board size</label>524 <input525 id="start-size"526 type="number"527 name="size"528 min="16"529 max="224"530 value="20"531 toolparamdescription="Square board width and height, from 16 to 224." />532 <button type="submit">start game</button>533 </form>534 <pre id="start-output" hidden></pre>535 </section>536537 <section>538 <h2>What your agent will do</h2>539 <p>It gets four tools. A typical game is 10–20 tool calls.</p>540 <dl class="tools">541 <dt>start_game</dt>542 <dd>Creates a logic-solvable board, from 16×16 up to 224×224. Every board is winnable without guessing.</dd>543 <dt>observe_game</dt>544 <dd>Returns the visible clues and frontier — never hidden mines.</dd>545 <dt>play_batch</dt>546 <dd>Submits flags and reveals in batches.</dd>547 <dt>watch_game</dt>548 <dd>Feeds the spectator view.</dd>549 </dl>550 </section>551552 <section>553 <h2>What you'll see</h2>554 <p>If your agent's client supports MCP Apps, a sandboxed spectator opens alongside the conversation: classic Minesweeper chrome, live moves, batch history, and replay. Every game gets a short share link (<code>/r/…</code>) that replays it; from there you can download a self-contained HTML file of the replay. The share link lasts as long as the server copy, the downloaded file forever.</p>555 </section>556557 <section>558 <h2>The boring details</h2>559 <ul class="details">560 <li>No auth, no accounts, nothing identifies you. The server stores only game state — board and moves — keyed by a random game ID.</li>561 <li>Games are deleted after 14 days of inactivity.</li>562 <li>Stateless Streamable HTTP endpoint at <code>/mcp</code>, running on Cloudflare Workers&nbsp;+&nbsp;D1.</li>563 </ul>564 </section>565566 <footer>567 <a href="/preview">open the spectator</a>568 </footer>569</main>570</body>571</html>`;572}