clone

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

Expose landing page tools via WebMCP

Dami committed 935aea7
- /webmcp.js registers observe_game and play_batch imperatively via
  navigator.modelContext and wires the declarative start_game form
  (toolname/tooldescription/toolparamdescription, respondWith)
- Form doubles as a human-usable game starter with inline output
- All tools proxy same-origin JSON-RPC to /mcp (SSE or JSON framing)
- CSP gains script-src/connect-src/form-action 'self'
- Fix stale D1 database name in db:migrate:local
commit
935aea7b11c9227dc24cbc97c8a449b4032f9786
parent
b3306f517a8bbe4ecaade8648cddde2adc3c2e03
author
Dami <dami.etoile@gmail.com>
3 files changed, +187 -3
Mpackage.json2+-
Msrc/index.ts11++-
Msrc/landing.ts177++++++++++++++++++++++++++++++-
M package.json +1 -1
M src/index.ts +10 -1
M src/landing.ts +176 -1
@@ -8,7 +8,98 @@ function escapeHtml(value: string): string {
88 }99 1010 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'";11+ "default-src 'none'; script-src 'self'; connect-src 'self'; style-src 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; base-uri 'none'; form-action 'self'; frame-ancestors 'none'";12+13+// 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.16+export const LANDING_SCRIPT = `(() => {17+ const MCP = new URL("/mcp", location.href).toString();18+ let nextId = 1;19+20+ 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+ }35+36+ 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+ }48+49+ 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+ });61+62+ const modelContext = navigator.modelContext || document.modelContext;63+ if (!modelContext || typeof modelContext.registerTool !== "function") return;64+65+ 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+ });80+81+ 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+`;12103 13104 export function landingPage(request: Request): string {14105 const mcpUrl = new URL("/mcp", request.url).toString();
@@ -22,6 +113,7 @@ export function landingPage(request: Request): string {
22113 <link rel="preconnect" href="https://fonts.googleapis.com" />23114 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />24115 <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" />116+<script src="/webmcp.js" defer></script>25117 <style>26118 :root {27119 --paper: #faf9f6;
@@ -247,6 +339,66 @@ export function landingPage(request: Request): string {
247339 .board .f::before { font-size: 7px; }248340 }249341 342+ form.start {343+ display: flex;344+ align-items: center;345+ gap: 10px;346+ font-family: "IBM Plex Mono", monospace;347+ font-size: 13.5px;348+ margin: 18px 0 0;349+ }350+351+ form.start label { color: var(--muted); }352+353+ form.start input {354+ font-family: "IBM Plex Mono", monospace;355+ font-size: 13.5px;356+ width: 5.5em;357+ padding: 5px 8px;358+ background: #ffffff;359+ color: var(--ink);360+ border-top: 2px solid var(--w98-shadow);361+ border-left: 2px solid var(--w98-shadow);362+ border-right: 2px solid var(--w98-light);363+ border-bottom: 2px solid var(--w98-light);364+ box-shadow: inset 1px 1px 0 var(--w98-dark);365+ border-radius: 0;366+ }367+368+ form.start button {369+ font-family: "IBM Plex Mono", monospace;370+ font-size: 13.5px;371+ font-weight: 500;372+ padding: 5px 16px;373+ color: var(--ink);374+ background: var(--w98-face);375+ border-top: 2px solid var(--w98-light);376+ border-left: 2px solid var(--w98-light);377+ border-right: 2px solid var(--w98-dark);378+ border-bottom: 2px solid var(--w98-dark);379+ border-radius: 0;380+ cursor: pointer;381+ }382+383+ form.start button:active {384+ border-top: 2px solid var(--w98-dark);385+ border-left: 2px solid var(--w98-dark);386+ border-right: 2px solid var(--w98-light);387+ border-bottom: 2px solid var(--w98-light);388+ }389+390+ #start-output {391+ font-family: "IBM Plex Mono", monospace;392+ font-size: 12.5px;393+ line-height: 1.6;394+ white-space: pre-wrap;395+ overflow-wrap: anywhere;396+ background: #f0eee8;397+ border: 1px solid var(--rule);398+ padding: 10px 12px;399+ margin: 12px 0 0;400+ }401+250402 dl.tools { margin: 0; }251403 252404 dl.tools dt {
@@ -364,6 +516,29 @@ export function landingPage(request: Request): string {
364516 </div>365517 </figure>366518 519+ <section>520+ <h2>Play from this page</h2>521+ <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>522+ <form523+ class="start"524+ id="start-form"525+ toolname="start_game"526+ 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."527+ toolautosubmit>528+ <label for="start-size">board size</label>529+ <input530+ id="start-size"531+ type="number"532+ name="size"533+ min="16"534+ max="224"535+ value="20"536+ toolparamdescription="Square board width and height, from 16 to 224." />537+ <button type="submit">start game</button>538+ </form>539+ <pre id="start-output" hidden></pre>540+ </section>541+367542 <section>368543 <h2>What your agent will do</h2>369544 <p>It gets four tools. A typical game is 10–20 tool calls.</p>