- /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>
| M | package.json | 2 | +- |
| M | src/ | 11 | ++- |
| M | src/ | 177 | ++++++++++++++++++++++++++++++- |
@@ -12,7 +12,7 @@
1212 "test": "vitest run",1313 "smoke": "node scripts/smoke.mjs",1414 "solve": "node scripts/solve.mjs",15- "db:migrate:local": "wrangler d1 migrations apply mcpsweeper-db --local --persist-to .wrangler/state"15+ "db:migrate:local": "wrangler d1 migrations apply mcpsweeper-agent-game-db --local --persist-to .wrangler/state"1616 },1717 "dependencies": {1818 "@modelcontextprotocol/ext-apps": "^1.7.5",@@ -1,6 +1,6 @@
11 import { createMcpHandler } from "@modelcontextprotocol/server";22 import viewHtml from "../dist/mcp-app.html";3-import { LANDING_CSP, landingPage } from "./landing.ts";3+import { LANDING_CSP, LANDING_SCRIPT, landingPage } from "./landing.ts";44 import { deleteGamesInactiveBefore } from "./repository.ts";55 import { createServer } from "./server.ts";66 @@ -49,6 +49,15 @@ export default {
4949 },5050 });5151 }52+ if (request.method === "GET" && url.pathname === "/webmcp.js") {53+ return new Response(LANDING_SCRIPT, {54+ headers: {55+ "Content-Type": "text/javascript; charset=utf-8",56+ "Cache-Control": "no-store",57+ "X-Content-Type-Options": "nosniff",58+ },59+ });60+ }5261 if (request.method === "GET" && url.pathname === "/preview") {5362 return new Response(viewHtml, {5463 headers: {@@ -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>