clone

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

Replace landing page with informational design and custom domain

Dami committed b3306f5
- New spec-document landing page in src/landing.ts: prompt-forward copy,
  CSS-only replay strip (three consistent 9x9 board snapshots), Win98
  bevel accents matching the 98.css spectator
- CSP now allows Google Fonts styles/fonts; YouTube frame-src removed
- Route the Worker at mcpsweeper.dev.site as a custom domain
commit
b3306f517a8bbe4ecaade8648cddde2adc3c2e03
parent
10aeee0f0655c7896b050d6f2363e6875e1baddc
author
Dami <dami.etoile@gmail.com>
3 files changed, +411 -46
Msrc/index.ts48+---
Asrc/landing.ts402++++++++++++++++++++++++++++++
Mwrangler.jsonc7+
M src/index.ts +2 -46
@@ -1,5 +1,6 @@
11 import { createMcpHandler } from "@modelcontextprotocol/server";22 import viewHtml from "../dist/mcp-app.html";3+import { LANDING_CSP, landingPage } from "./landing.ts";34 import { deleteGamesInactiveBefore } from "./repository.ts";45 import { createServer } from "./server.ts";56
@@ -9,51 +10,6 @@ function jsonError(message: string, status: number): Response {
910 return Response.json({ error: message }, { status });1011 }1112 12-function escapeHtml(value: string): string {13- return value14- .replaceAll("&", "&amp;")15- .replaceAll("<", "&lt;")16- .replaceAll(">", "&gt;")17- .replaceAll('"', "&quot;")18- .replaceAll("'", "&#039;");19-}20-21-function landingPage(request: Request): string {22- const mcpUrl = new URL("/mcp", request.url).toString();23-24- return `<!doctype html>25-<html lang="en">26- <head>27- <meta charset="UTF-8" />28- <meta name="viewport" content="width=device-width, initial-scale=1.0" />29- <title>MCPSweeper</title>30- </head>31- <body>32- <main>33- <h1>MCPSweeper</h1>34- <p>MCPSweeper is a reference implementation and evaluation environment for the July 28, 2026 Model Context Protocol specification and the MCP Apps specification.</p>35-36- <section>37- <h2>July 2026 protocol</h2>38- <p>It serves a no-guess Minesweeper game through the new stateless protocol core, with cacheable discovery for its app resource.</p>39- </section>40-41- <section>42- <h2>MCP Apps</h2>43- <p>The spectator is delivered as a sandboxed app attached to the game tools. It displays and replays the model's actions without exposing hidden mines during play.</p>44- </section>45-46- <section>47- <h2>Evaluation tool</h2>48- <p>Use the game to evaluate constraint solving, tool use, state tracking, and long task reliability across a model and its harness.</p>49- <p>Suggested prompt:</p>50- <blockquote>Install the MCPSweeper MCP server at <code>${escapeHtml(mcpUrl)}</code>, start a 20×20 game, and play autonomously until you win.</blockquote>51- </section>52- </main>53- </body>54-</html>`;55-}56-5713 function isAllowedLocalHost(request: Request): boolean {5814 const hostname = new URL(request.url).hostname;5915 if (hostname !== "localhost" && hostname !== "127.0.0.1" && hostname !== "[::1]") return true;
@@ -87,7 +43,7 @@ export default {
8743 headers: {8844 "Content-Type": "text/html; charset=utf-8",8945 "Cache-Control": "no-store",90- "Content-Security-Policy": "default-src 'none'; base-uri 'none'; frame-ancestors 'none'",46+ "Content-Security-Policy": LANDING_CSP,9147 "X-Content-Type-Options": "nosniff",9248 "Referrer-Policy": "no-referrer",9349 },
A src/landing.ts +402 -0
@@ -0,0 +1,402 @@
1+function escapeHtml(value: string): string {2+ return value3+ .replaceAll("&", "&amp;")4+ .replaceAll("<", "&lt;")5+ .replaceAll(">", "&gt;")6+ .replaceAll('"', "&quot;")7+ .replaceAll("'", "&#039;");8+}9+10+export const LANDING_CSP =11+ "default-src 'none'; style-src 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; base-uri 'none'; frame-ancestors 'none'";12+13+export function landingPage(request: Request): string {14+ const mcpUrl = new URL("/mcp", request.url).toString();15+16+ return `<!doctype html>17+<html lang="en">18+<head>19+<meta charset="UTF-8" />20+<meta name="viewport" content="width=device-width, initial-scale=1.0" />21+<title>MCPSweeper</title>22+<link rel="preconnect" href="https://fonts.googleapis.com" />23+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />24+<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" />25+<style>26+ :root {27+ --paper: #faf9f6;28+ --ink: #1c1b18;29+ --muted: #6b6961;30+ --rule: #dcdad2;31+ --accent: #00007a;32+ --w98-face: #c0c0c0;33+ --w98-light: #ffffff;34+ --w98-shadow: #808080;35+ --w98-dark: #404040;36+ }37+38+ * { box-sizing: border-box; }39+40+ html { background: var(--paper); }41+42+ body {43+ margin: 0;44+ color: var(--ink);45+ font-family: "IBM Plex Serif", Georgia, serif;46+ font-size: 17px;47+ line-height: 1.65;48+ -webkit-font-smoothing: antialiased;49+ }50+51+ main {52+ max-width: 620px;53+ margin: 0 auto;54+ padding: 72px 24px 96px;55+ }56+57+ header { margin-bottom: 40px; }58+59+ h1 {60+ font-family: "IBM Plex Mono", monospace;61+ font-weight: 600;62+ font-size: 34px;63+ letter-spacing: -0.01em;64+ margin: 0 0 14px;65+ }66+67+ h1 .cell {68+ display: inline-block;69+ width: 0.62em; height: 0.62em;70+ margin-left: 0.28em;71+ vertical-align: baseline;72+ background: var(--w98-face);73+ border-top: 3px solid var(--w98-light);74+ border-left: 3px solid var(--w98-light);75+ border-right: 3px solid var(--w98-shadow);76+ border-bottom: 3px solid var(--w98-shadow);77+ }78+79+ .lede {80+ font-size: 19px;81+ margin: 0;82+ }83+84+ h2 {85+ font-family: "IBM Plex Mono", monospace;86+ font-weight: 500;87+ font-size: 13px;88+ letter-spacing: 0.12em;89+ text-transform: uppercase;90+ color: var(--muted);91+ margin: 52px 0 14px;92+ padding-top: 20px;93+ border-top: 1px solid var(--rule);94+ }95+96+ p { margin: 0 0 1em; }97+ p:last-child { margin-bottom: 0; }98+99+ code {100+ font-family: "IBM Plex Mono", monospace;101+ font-size: 0.85em;102+ background: #f0eee8;103+ border: 1px solid var(--rule);104+ border-radius: 2px;105+ padding: 0.08em 0.35em;106+ white-space: nowrap;107+ }108+109+ a { color: var(--accent); text-underline-offset: 2px; }110+111+ .prompt-label {112+ font-family: "IBM Plex Mono", monospace;113+ font-size: 13px;114+ font-weight: 500;115+ margin: 28px 0 10px;116+ }117+118+ .prompt {119+ background: var(--w98-face);120+ border-top: 3px solid var(--w98-shadow);121+ border-left: 3px solid var(--w98-shadow);122+ border-right: 3px solid var(--w98-light);123+ border-bottom: 3px solid var(--w98-light);124+ box-shadow: inset 1px 1px 0 var(--w98-dark);125+ padding: 3px;126+ margin: 0 0 10px;127+ }128+129+ .prompt blockquote {130+ margin: 0;131+ background: #ffffff;132+ padding: 14px 16px;133+ font-family: "IBM Plex Mono", monospace;134+ font-size: 13.5px;135+ line-height: 1.6;136+ user-select: all;137+ -webkit-user-select: all;138+ cursor: text;139+ }140+141+ .prompt blockquote code {142+ background: none; border: none; padding: 0; font-size: inherit;143+ color: var(--accent);144+ white-space: normal;145+ overflow-wrap: anywhere;146+ }147+148+ .prompt-hint {149+ font-family: "IBM Plex Mono", monospace;150+ font-size: 12px;151+ color: var(--muted);152+ margin: 0;153+ }154+155+ figure.replay {156+ margin: 40px 0 0;157+ }158+159+ .replay-loop {160+ font-family: "IBM Plex Mono", monospace;161+ font-size: 12.5px;162+ color: var(--muted);163+ margin: 0 0 16px;164+ }165+166+ .replay-loop code {167+ background: none; border: none; padding: 0;168+ color: var(--ink);169+ font-size: inherit;170+ }171+172+ .strip {173+ display: flex;174+ align-items: flex-start;175+ gap: 10px;176+ }177+178+ .snap { flex: 1; min-width: 0; margin: 0; }179+180+ .board {181+ display: grid;182+ grid-template-columns: repeat(9, minmax(0, 1fr));183+ gap: 1px;184+ background: var(--w98-shadow);185+ border-top: 3px solid var(--w98-shadow);186+ border-left: 3px solid var(--w98-shadow);187+ border-right: 3px solid var(--w98-light);188+ border-bottom: 3px solid var(--w98-light);189+ }190+191+ .board i {192+ aspect-ratio: 1;193+ min-width: 0;194+ overflow: hidden;195+ display: grid;196+ place-items: center;197+ font-family: "IBM Plex Mono", monospace;198+ font-style: normal;199+ font-weight: 600;200+ font-size: 11px;201+ background: var(--w98-face);202+ }203+204+ .board .u {205+ box-shadow: inset 2px 2px 0 var(--w98-light), inset -2px -2px 0 var(--w98-shadow);206+ }207+208+ .board .r { background: #cbc9c1; }209+210+ .board .f {211+ box-shadow: inset 2px 2px 0 var(--w98-light), inset -2px -2px 0 var(--w98-shadow);212+ }213+214+ .board .f::before { content: "\\2691"; color: #c00000; font-size: 10px; }215+216+ .board .n1 { color: #0000c8; }217+ .board .n2 { color: #007000; }218+ .board .n3 { color: #c00000; }219+ .board .n4 { color: #000078; }220+221+ .snap figcaption {222+ font-family: "IBM Plex Mono", monospace;223+ font-size: 11.5px;224+ line-height: 1.5;225+ color: var(--muted);226+ margin-top: 8px;227+ }228+229+ .snap figcaption b {230+ display: block;231+ font-weight: 600;232+ color: var(--ink);233+ }234+235+ .strip .arrow {236+ align-self: center;237+ font-family: "IBM Plex Mono", monospace;238+ color: var(--muted);239+ flex: none;240+ padding-bottom: 32px;241+ }242+243+ @media (max-width: 480px) {244+ .strip { gap: 6px; }245+ .strip .arrow { display: none; }246+ .board i { font-size: 8px; }247+ .board .f::before { font-size: 7px; }248+ }249+250+ dl.tools { margin: 0; }251+252+ dl.tools dt {253+ font-family: "IBM Plex Mono", monospace;254+ font-size: 14px;255+ font-weight: 600;256+ margin-top: 14px;257+ }258+259+ dl.tools dt:first-child { margin-top: 0; }260+261+ dl.tools dd {262+ margin: 2px 0 0 0;263+ }264+265+ ul.details {266+ margin: 0; padding: 0;267+ list-style: none;268+ }269+270+ ul.details li {271+ padding-left: 1.4em;272+ position: relative;273+ margin-bottom: 0.7em;274+ }275+276+ ul.details li::before {277+ content: "";278+ position: absolute;279+ left: 0; top: 0.48em;280+ width: 9px; height: 9px;281+ background: var(--w98-face);282+ border-top: 2px solid var(--w98-light);283+ border-left: 2px solid var(--w98-light);284+ border-right: 2px solid var(--w98-shadow);285+ border-bottom: 2px solid var(--w98-shadow);286+ }287+288+ footer {289+ margin-top: 64px;290+ padding-top: 20px;291+ border-top: 1px solid var(--rule);292+ font-family: "IBM Plex Mono", monospace;293+ font-size: 12px;294+ color: var(--muted);295+ }296+297+ @media (max-width: 480px) {298+ body { font-size: 16px; }299+ main { padding-top: 48px; }300+ h1 { font-size: 28px; }301+ }302+</style>303+</head>304+<body>305+<main>306+ <header>307+ <h1>MCPSweeper<span class="cell" aria-hidden="true"></span></h1>308+ <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>309+ </header>310+311+ <p class="prompt-label">Try it — paste this into your agent:</p>312+ <div class="prompt">313+ <blockquote>Install the MCPSweeper MCP server at <code>${escapeHtml(mcpUrl)}</code>, start a 20×20 game, and play autonomously until you win.</blockquote>314+ </div>315+ <p class="prompt-hint">click to select the whole prompt</p>316+317+ <figure class="replay">318+ <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>319+ <div class="strip">320+ <figure class="snap">321+ <div class="board" aria-label="Board after start_game: a 30-cell opening in the top-left corner">322+<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>323+<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>324+<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>325+<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>326+<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>327+<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>328+<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>329+<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>330+<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>331+ </div>332+ <figcaption><b>start_game</b>30 cells open for free</figcaption>333+ </figure>334+ <span class="arrow" aria-hidden="true">→</span>335+ <figure class="snap">336+ <div class="board" aria-label="Board after a play_batch: 27 more cells revealed and 9 mines flagged">337+<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>338+<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>339+<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>340+<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>341+<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>342+<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>343+<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>344+<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>345+<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>346+ </div>347+ <figcaption><b>play_batch</b>27 reveals, 9 flags, all forced by clues</figcaption>348+ </figure>349+ <span class="arrow" aria-hidden="true">→</span>350+ <figure class="snap">351+ <div class="board" aria-label="Finished board: every safe cell revealed, all 10 mines flagged">352+<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>353+<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>354+<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>355+<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>356+<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>357+<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>358+<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>359+<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>360+<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>361+ </div>362+ <figcaption><b>won</b>every safe cell, zero guesses</figcaption>363+ </figure>364+ </div>365+ </figure>366+367+ <section>368+ <h2>What your agent will do</h2>369+ <p>It gets four tools. A typical game is 10–20 tool calls.</p>370+ <dl class="tools">371+ <dt>start_game</dt>372+ <dd>Creates a logic-solvable board, from 16×16 up to 224×224. Every board is winnable without guessing.</dd>373+ <dt>observe_game</dt>374+ <dd>Returns the visible clues and frontier — never hidden mines.</dd>375+ <dt>play_batch</dt>376+ <dd>Submits flags and reveals in batches.</dd>377+ <dt>watch_game</dt>378+ <dd>Feeds the spectator view.</dd>379+ </dl>380+ </section>381+382+ <section>383+ <h2>What you'll see</h2>384+ <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. Finished games produce a shareable replay link that's fully self-contained in the URL — no account, no server lookup.</p>385+ </section>386+387+ <section>388+ <h2>The boring details</h2>389+ <ul class="details">390+ <li>No auth, no accounts, nothing identifies you. The server stores only game state — board and moves — keyed by a random game ID.</li>391+ <li>Games are deleted after 14 days of inactivity.</li>392+ <li>Stateless Streamable HTTP endpoint at <code>/mcp</code>, running on Cloudflare Workers&nbsp;+&nbsp;D1.</li>393+ </ul>394+ </section>395+396+ <footer>397+ <a href="/preview">open the spectator</a>398+ </footer>399+</main>400+</body>401+</html>`;402+}
M wrangler.jsonc +7 -0