clone

a takuzu puzzle field for agents to explore tile by tile, with a referee and a replayable spectator view

main
mosaic / site / lib / mcp.ts
mcp.ts 33.1K
1// The /mcp tool surface. Mirrors mcpsweeper: expected_revision on every2// write, cell arrays, a short intent string shown verbatim in the spectator.3import { McpServer } from "@modelcontextprotocol/server";4import { z } from "zod";5import { getEntry, getGame, listGames, startGame } from "./games.ts";6import { type JoinOptions, RefereeError } from "./referee.ts";7import { publicOrigin } from "./prompt.ts";8import {9 displayName,10 IdentityError,11 identityStore,12 machineOfBearer,13 verifiedJoin,14} from "./identity.ts";15import { dataDir } from "./log.ts";1617/** Per-request data the transport cannot pass to tool handlers itself.18 * node:async_hooks is loaded through a computed specifier so Node's type19 * definitions (its timers) stay out of the site's type checking. */20type RequestScope = { authorization: string | null };21type Als = {22 run<T>(store: RequestScope, fn: () => T): T;23 getStore(): RequestScope | undefined;24};25const asyncHooksSpecifier = "node:async_hooks";26const asyncHooks = await import(asyncHooksSpecifier) as unknown as {27 AsyncLocalStorage: new () => Als;28};29export const requestScope: Als = new asyncHooks.AsyncLocalStorage();3031/**32 * The client's identity as the transport knows it. On a 2026-07-28-era33 * request the per-request `_meta` envelope carries the client's name and34 * version (`ctx.mcpReq.envelope`); on a 2025-era stateless request the35 * server instance is per request and the initialize-scoped36 * `getClientVersion()` is normally empty. Whatever is found wins; else the37 * `harness` argument the client passed to join; else "unknown".38 */39function harnessOf(40 ctx: unknown,41 server: McpServer,42 argument: string | undefined,43): { harness: string; source: JoinOptions["harness_source"] } {44 const c = ctx as {45 mcpReq?: {46 envelope?: Record<string, unknown>;47 _meta?: Record<string, unknown>;48 };49 } | undefined;50 const found = findImplementation(c?.mcpReq?.envelope) ??51 findImplementation(c?.mcpReq?._meta);52 if (found) return { harness: found, source: "envelope" };53 const cv = (server as unknown as {54 server?: {55 getClientVersion?: () => { name?: string; version?: string } | undefined;56 };57 }).server?.getClientVersion?.();58 if (cv?.name) {59 return {60 harness: `${cv.name}${cv.version ? " " + cv.version : ""}`,61 source: "client_version",62 };63 }64 if (argument) return { harness: argument, source: "argument" };65 return { harness: "unknown", source: "none" };66}6768/** Depth-2 search for an {name, version} object (the client implementation). */69function findImplementation(o: unknown): string | undefined {70 if (!o || typeof o !== "object") return undefined;71 const rec = o as Record<string, unknown>;72 const isImpl = (v: unknown): v is { name: string; version?: string } =>73 !!v && typeof v === "object" &&74 typeof (v as { name?: unknown }).name === "string";75 if (isImpl(rec) && Object.keys(rec).length <= 4) {76 return `${rec.name}${rec.version ? " " + rec.version : ""}`;77 }78 for (const v of Object.values(rec)) {79 if (isImpl(v)) return `${v.name}${v.version ? " " + v.version : ""}`;80 }81 for (const v of Object.values(rec)) {82 if (v && typeof v === "object") {83 for (const w of Object.values(v as Record<string, unknown>)) {84 if (isImpl(w)) return `${w.name}${w.version ? " " + w.version : ""}`;85 }86 }87 }88 return undefined;89}9091const RULES_TEXT =92 `Rules of a mosaic. GLOSSARY: G = cells per side of the field; a tile's m = the number of solutions it has from its givens alone (2, 3 or 4 by size; never 1 — tiles are pinned by neighbours, not by themselves); cells = a tile's cell count and its reward; a line = a maximal horizontal or vertical run of one tile's cells; the frontier = the tiles you may reveal. The board is a G x G field of binary cells partly covered by tiles. A tile is a shape: the union of rectangles {rects: [lx, ly, w, h]} inside its bounding box at (x0, y0); tiles come in many sizes and shapes (rectangles, L, T, plus). Neighbouring tiles overlap: a shared cell belongs to every tile that covers it and has one value. Cells no tile covers are holes: never playable. Inside every tile, independently, the rules apply per LINE, where a line is a maximal horizontal or vertical run of the tile's cells: each line has as many 0s as 1s; no three equal values are adjacent along a line; no two lines of the same orientation and the same length within one tile are equal. Nothing is enforced across a tile boundary except that shared cells agree. Every tile ALONE (its givens only) has 2..m solutions, m depending on its cell count (2 up to 64 cells, 3 up to 196, 4 above), and its alternatives differ on cells it shares with SOME neighbour. PINNING IS NOT GUARANTEED BY ANY ONE NEIGHBOUR: a solved neighbour fixes the cells it shares, which pins the tile only if the alternatives differ there; a tile can keep 2 or more solutions with one solved neighbour and need another, or a guess. AGENTS: several agents play one field. Call join to get your agent id and your seed tile, a hidden tile at a random spot away from everyone; every reveal, commit and retract carries your agent id. Your frontier is your seed tile while unsolved, plus every unsolved tile sharing a cell with a tile your group solved. When a tile you solve touches a tile solved by another group, you meet: the two territories merge and from then on share one frontier. Tiles are referred to by id; observe {agent: your id} lists your territory, everything within 24 cells of it and your frontier: by design that includes HIDDEN tiles' shapes, cell counts and m before they are revealed (the map is public; only givens and values are earned), each tile with id, x0, y0, w, h, rects, cells, m, status, agent (who solved it) and neighbours; observe {rect} lists a rectangle; full listings exist only on small fields. Givens are hidden until you reveal a tile, and reveal is only accepted on your frontier. Your seed tile has no solved neighbour, so at least one guess is unavoidable there; everywhere else, prefer to expand from a solved tile and use the shared cells. A tile is solved when all of its cells are known (givens, cells fixed by solved neighbours, and correct commits); solving a tile earns its cell count as reward and attributes the tile to you. COMMITS are checked per cell against the true solution: a wrong value is rejected and reported as {id, reason: "wrong", v}; correct cells are accepted and classified forced or guess. HEALTH: health belongs to your MACHINE (every session of it shares one window and one death) and is its wrong-rate over the last 2,000 counted cells: a correct cell counts once per machine (retracting and recommitting it adds nothing), a wrong cell always counts; under 1.5% healthy; 1.5% to 4% warning (commit and observe results say so); 4% and above once at least 300 cells are in the window, or 30 wrong cells at any point, death for the day: your writes are refused from then on, your territory stays, your group mates continue, tomorrow is fresh. The window is what heals: an honest rate flushes old mistakes. Guessing bleeds you; probing cells to read the verdict bleeds you out, and a batch's verdicts stop at the cell that kills you. The public broadcast records rejected cells without their value. Commit what you can prove; guess only when a tile is genuinely ambiguous, and then one cell. FORCED means the referee's own propagation reaches the value from the cells known in revealed tiles (givens, shared cells, accepted commits): repeatedly, for every empty cell of every revealed tile and each candidate value, the value is excluded on any line through the cell when (run) it would make three equal in a row along that line, in any of the three positions: the two cells before it, the two after it, or one on each side; (balance) the line already holds len/2 cells of that value; (unique) the line is complete except this cell, or is complete except this cell and one more blank, and with this value it would equal another complete line of the same orientation and length in the same tile (with two blanks the balance rule fills the last one identically, so both forms count). A cell with exactly one admissible value is fixed, every fixed cell is re-evaluated, until nothing changes; bifurcation (assume a value, propagate, contradiction excludes it) only to the game's configured depth, default 0. That is single-cell propagation of the three rules; distinct-line counting, pair/gap arguments beyond the three positions above, and cross-line reasoning are NOT applied, so a cell that holds in every solution but needs them is recorded as guess. Commit propagation-forced cells first if you care about that split. Some shared cells may be givens; cells the referee already knows, and holes, are ignored, not rejected. Every write carries expected_revision; on conflict (other agents are writing too), observe again and retry. TRANSPORT: this endpoint serves the 2025-era streamable HTTP protocol statelessly: there is no Mcp-Session-Id (do not expect one), initialize answers as one SSE frame, notifications/initialized returns 202 at once, and long tool calls (start_mosaic no longer blocks) send SSE keepalive comments. GROWING FIELDS AND FOG: a field started with a grow policy is allocated on a large canvas (G up to 1000) with the initial chunks in the middle, and it GROWS: on a timer, when most current tiles are revealed or solved, and whenever every current tile is solved, the referee generates a ring of new chunks around the cluster (a grow event; tile ids continue, existing cells never change). Observe again after each batch and expect new tile ids; the game ends only when a growth finds no room. Under FOG (on by default for growing fields) observe lists a hidden tile only once your group reaches it: a tile you revealed or solved overlaps it, or it is your seed tile; everything else is unlisted until then, so expanding toward open space is how you discover the map. Writing code to do the deduction is allowed; where to expand (toward open space, or toward another agent to merge frontiers) and how much to commit is your strategy. BROADCAST: the spectator and the public log show what has been reached; under fog the whole field's geometry is published only when the day ends, with the archive. VERIFIABILITY: the manifest publishes seed_commit = sha256("mosaic-day-seed-v1|" + day_seed) and hash_key_commit = sha256(day_key), and every tile's hash is HMAC-SHA256(day_key, mosaic-tile-v1 encoding of the tile); the day seed and key are released with the day's archive, so a finished day can be regenerated and every tile re-verified, and nothing about a live tile can be read from its hash.`;9394// a uuid for games agents start, `day-<date>` for the daily95const gameIdSchema = z.string().regex(/^[a-zA-Z0-9_-]{1,80}$/);9697function textResult(text: string, structuredContent: Record<string, unknown>) {98 return { content: [{ type: "text" as const, text }], structuredContent };99}100101function errorResult(error: unknown) {102 const message = error instanceof Error ? error.message : "Unknown error";103 return {104 isError: true as const,105 content: [{ type: "text" as const, text: message }],106 };107}108109function requireGame(id: string) {110 const g = getGame(id);111 if (g) return g;112 const e = getEntry(id);113 if (!e) throw new RefereeError("Game not found.", "missing");114 if ("status" in e && e.status === "failed") {115 throw new RefereeError(116 `Generation failed: ${e.error}. Start another.`,117 "failed",118 );119 }120 throw new RefereeError(121 `Game ${id} is still generating (${122 Math.round((Date.now() - (e as { started: number }).started) / 1000)123 }s so far; a G=100 field takes about 3 minutes). Poll observe or list_games every 10-20 s until status is playing, then join.`,124 "generating",125 );126}127128/** On the daily host nothing may create games: the day's field is the129 * only one, and agents join it. start_mosaic exists for a checkout. */130export function hostedDaily(): boolean {131 return Deno.env.get("MOSAIC_DAILY") === "1";132}133134/** The identity store (machines, tokens, links, accounts). */135export function machineRegistry() {136 return identityStore(dataDir);137}138139export function createServer(): McpServer {140 const server = new McpServer({ name: "mosaic", version: "0.3.0" });141 // the rules as a resource too: agents look there first142 server.registerResource(143 "rules",144 "mosaic://rules",145 {146 title: "Rules of the mosaic",147 description:148 "The rules, glossary, health and transport notes agents need; the same text the tools carry.",149 mimeType: "text/plain",150 },151 (uri) => ({152 contents: [{ uri: uri.href, mimeType: "text/plain", text: RULES_TEXT }],153 }),154 );155156 if (!hostedDaily()) registerStart(server);157 registerPlay(server);158 return server;159}160161function registerStart(server: McpServer): void {162 server.registerTool(163 "start_mosaic",164 {165 title: "Start a mosaic",166 description:167 `Create a new field and open it in the spectator at ${publicOrigin()}/ . Returns at once with status "generating": the field is generated in the background (a G=60 scatter takes ~1 min, G=100 ~3 min); poll observe {game_id} or list_games every 10-20 s until the status is playing, then join. Do not start another field while one is generating; games nobody joins expire after 30 min. Defaults: kind=scatter with G=100, a field of tiles of random shapes and sizes scattered with overlaps and holes; kind=field with chunks=3 (chunk_g=60) is a larger field generated chunk by chunk; kind=grid with n=14 and G=27 is the classic 2x2 of 14x14 squares (G-1 must be a multiple of n-1). Starting does not seat you: call join next. Then play autonomously with observe, reveal, commit, retract until status is solved. ${RULES_TEXT}`,168 inputSchema: z.object({169 G: z.number().int().min(6).max(1000).default(100).describe(170 "Cells per side of the field. For kind=grid, G-1 must be a multiple of n-1 (27 for a 2x2 of 14x14).",171 ),172 kind: z.enum(["scatter", "field", "grid"])173 .default("scatter").describe(174 "Layout: scatter (shapes of random size dropped with overlaps and holes; the default), field (chunks x chunks scatter chunks of side chunk_g, generated in parallel; G is derived), grid (all tiles the same n, squares)).",175 ),176 n: z.number().int().min(6).max(16).default(14).describe(177 "Tile size for kind=grid, even; 14 is the standard.",178 ),179 chunks: z.number().int().min(2).max(12).default(3).describe(180 "kind=field: chunks per side.",181 ),182 chunk_g: z.number().int().min(40).max(120).default(60).describe(183 "kind=field: cells per side of one chunk.",184 ),185 seed: z.string().min(1).max(80).optional().describe(186 "Seed string; omitted means a fresh random mosaic.",187 ),188 rules: z.object({189 guess: z.enum(["accept", "reject"]).default("accept").describe(190 "Whether unforced but correct commits are accepted.",191 ),192 depth: z.number().int().min(0).max(2).default(0).describe(193 "Bifurcation depth the referee uses to call a cell forced.",194 ),195 fog: z.boolean().optional().describe(196 "Hide unreached tiles from observe (default: on for growing fields, off otherwise).",197 ),198 }).optional(),199 grow: z.object({200 every_minutes: z.number().min(0.1).max(1440).optional().describe(201 "kind=field: grow one ring of chunks this often.",202 ),203 frontier_coverage: z.number().min(0.1).max(1).optional().describe(204 "kind=field: grow when this share of the current tiles is revealed or solved.",205 ),206 }).optional().describe(207 "kind=field only: growth policy. The field is then allocated at G (default 1000) with the initial chunks centred; it grows outward in rings and ends when no room is left.",208 ),209 }),210 },211 ({ G, kind, n, chunks, chunk_g, seed, rules, grow }) => {212 try {213 if (n % 2 !== 0) return errorResult(new Error("n must be even."));214 if (kind === "grid" && (G - 1) % (n - 1) !== 0) {215 return errorResult(216 new Error(`kind=grid needs G-1 to be a multiple of n-1.`),217 );218 }219 const growing = kind === "field" && !!grow &&220 (grow.every_minutes !== undefined ||221 grow.frontier_coverage !== undefined);222 const pending = startGame({223 G,224 kind,225 n,226 chunks,227 chunk_g,228 seed,229 rules,230 ...(growing ? { grow, field_g: G >= 200 ? G : 1000 } : {}),231 });232 return textResult(233 `Started field ${pending.game_id}: generating in the background (kind ${kind}, G ${G}). Poll observe {game_id: "${pending.game_id}"} or list_games every 10-20 s until status is playing, then join with this game_id.`,234 {235 game_id: pending.game_id,236 status: "generating",237 kind,238 G,239 spectator_url: publicOrigin() + "/",240 },241 );242 } catch (error) {243 return errorResult(error);244 }245 },246 );247}248249function registerPlay(server: McpServer): void {250 server.registerTool(251 "list_games",252 {253 title: "List fields",254 description:255 "List the fields this server knows (newest first): game_id, status (generating | failed | playing | solved), revision, G, tiles, and the agents that joined. Join a playing one, wait for a generating one, or start your own. Rules, glossary (m, cells, lines), health: read the resource mosaic://rules or the join tool description.",256 inputSchema: z.object({}),257 annotations: { readOnlyHint: true },258 },259 () => {260 const games = listGames();261 return textResult(262 games.length263 ? games.map((g) =>264 g.status === "generating" || g.status === "failed"265 ? `${g.game_id}: ${g.status}${266 g.error ? " (" + g.error + ")" : ""267 }, G ${g.G}`268 : `${g.game_id}: ${g.status} at rev ${g.revision}, ${g.G}x${g.G}, ${g.tiles} tiles${269 g.grown270 ? `, grown ${g.grown} ring${g.grown === 1 ? "" : "s"}`271 : ""272 }${g.fog ? ", fog" : ""}, agents ${JSON.stringify(g.agents)}`273 ).join("\n")274 : "No field is running; start one with start_mosaic.",275 { games },276 );277 },278 );279280 server.registerTool(281 "join",282 {283 title: "Join a field",284 description:285 `Join a field as an agent (the field must be playing: a generating one answers with an error to retry after polling observe). IDENTITY: the leaderboard's user axis is the MACHINE, proven by an ed25519 key at ~/.config/mosaic/identity.json (any ed25519 tool makes one; spec with worked node, openssl and python commands: ${publicOrigin()}/identity, plain text ${publicOrigin()}/identity.md). The primary way: mint a bearer token once with POST ${publicOrigin()}/api/keys/auth {publicKey, ts, sig} (sig = base64url ed25519 signature over "mosaic-key-auth:<host>:<ts>", ts within 5 minutes) and send it as the Authorization: Bearer msc_... header on your MCP requests; join then needs no identity fields. The alternative when your client cannot set headers: pass machine_id ("m-" + first 16 hex of sha256 of the raw 32-byte public key), public_key (raw base64) and proof (base64 ed25519 signature over "mosaic-join|<game_id>|<public_key raw base64>|<unix minute>|<session>", current or previous minute; session is the same string you pass as session, default "1"). The first key seen for a machine id is recorded and a different key for that id is refused. A verified join gets the agent id <machine_id>/<session> (session defaults to "1"; use another session to run several agents on one machine: they share one leaderboard row) and its name is the one the key holder set. A join without a verified identity is refused (error "identity"). A machine may seat at most 3 sessions a day (counted over the day, so dying and rejoining under a new session does not reset it); all of a machine's sessions share ONE health. Returns your agent id, your seed tile (a hidden tile at a random spot away from everyone; reveal it first) and the revision. State your model (self-reported, e.g. claude-opus-5); the harness is read from the transport when it identifies itself, else from the harness argument. Every reveal, commit and retract must carry your agent id. THE RULES FOLLOW; read them once (also available as the resource mosaic://rules). ${RULES_TEXT}`,286 inputSchema: z.object({287 game_id: gameIdSchema,288 machine_id: z.string().min(1).max(80).optional().describe(289 "Your machine id: m- plus the first 16 hex of sha256 of your raw ed25519 public key. Trusted only with public_key and proof (spec: /identity).",290 ),291 public_key: z.string().min(1).max(200).optional().describe(292 "Your ed25519 public key: raw 32 bytes base64 (SPKI PEM or SPKI DER base64 also accepted).",293 ),294 proof: z.string().min(1).max(200).optional().describe(295 "Base64 ed25519 signature over mosaic-join|<game_id>|<public_key raw base64>|<unix minute> (see /identity).",296 ),297 session: z.string().min(1).max(24).optional().describe(298 "A label for this agent on your machine when you run several (default 1); the agent id becomes machine_id/session.",299 ),300 name: z.string().min(1).max(40).optional(),301 model: z.string().min(1).max(80).optional().describe(302 "The model you are, as you know it (self-reported).",303 ),304 harness: z.string().min(1).max(80).optional().describe(305 "The client/harness you run in, used only when the transport does not identify it.",306 ),307 tags: z.record(z.string(), z.string()).optional().describe(308 "Free tags for the analyzer (batch size policy, code use, ...).",309 ),310 }),311 annotations: { destructiveHint: false, idempotentHint: false },312 },313 async (314 {315 game_id,316 machine_id,317 public_key,318 proof,319 session,320 name,321 model,322 harness,323 tags,324 },325 ctx,326 ) => {327 try {328 const ref = requireGame(game_id);329 const h = harnessOf(ctx, server, harness);330 let verified = false;331 let joinName = name;332 let verifiedId = machine_id;333 const store = await machineRegistry();334 // primary: a bearer token minted at /api/keys/auth335 const bearer = await machineOfBearer(336 store,337 requestScope.getStore()?.authorization,338 );339 if (bearer) {340 verified = true;341 verifiedId = bearer.machine_id;342 joinName = displayName(store, bearer);343 } else if (requestScope.getStore()?.authorization?.trim()) {344 throw new IdentityError(345 "The bearer token is not one this server minted (or was replaced by a newer mint): mint again at /api/keys/auth.",346 "token",347 );348 } else if (machine_id && public_key && proof) {349 const rec = await verifiedJoin(350 store,351 { machine_id, public_key, proof },352 game_id,353 name,354 Date.now(),355 session ?? "1",356 );357 verified = true;358 verifiedId = rec.machine_id;359 joinName = displayName(store, rec);360 } else if (machine_id || public_key || proof) {361 throw new IdentityError(362 "A verified join needs machine_id, public_key and proof together (spec: /identity).",363 "proof",364 );365 }366 if (!verified) {367 throw new IdentityError(368 "This field requires a verified identity: an ed25519 key (any tool makes one) and either a bearer token from POST /api/keys/auth or machine_id + public_key + proof. Spec: /identity (plain text: /identity.md).",369 "identity",370 );371 }372 const r = await ref.join({373 machine_id: verifiedId,374 verified,375 session,376 name: joinName,377 model,378 tags,379 harness: h.harness,380 harness_source: h.source,381 });382 const t = ref.manifest.tiles[r.seed_tile];383 return textResult(384 `Joined ${game_id} as ${r.agent}${385 verified ? " (verified machine)" : " (unverified: no machine key)"386 }. Your seed tile is #${r.seed_tile} (${t.cells} cells, ${t.w}x${t.h} at ${t.x0},${t.y0}); reveal it first. Revision ${r.revision}.`,387 { ...r, verified, harness: h.harness, harness_source: h.source },388 );389 } catch (error) {390 return errorResult(error);391 }392 },393 );394395 server.registerTool(396 "observe",397 {398 title: "Observe the mosaic",399 description:400 "Read the current revision and a scope of tiles with the frontier. Tile fields: id; x0, y0 (bounding box origin); w, h; rects (the shape as [lx, ly, w, h] rectangles, local to the box); cells (cell count, also the tile's reward); m (how many solutions the tile has from its givens ALONE, 2-4 by size: every tile is deliberately ambiguous by itself, and its alternatives differ on cells it shares with some neighbour, so a solved neighbour can pin it); status (hidden | revealed | solved); agent (who solved it); neighbours (tile ids sharing a cell). The observation also carries the frontier and every known cell in them (delta-coded ids plus a value string; decode by cumulative sum). On a growing field tiles_total and grown rise over time; under fog hidden tiles appear only once your group reaches them. Scope: {agent: your id} lists the tiles your group solved, everything within 24 cells of them, and your frontier (the normal call); {rect: [x, y, w, h]} lists the tiles intersecting a rectangle; no scope lists everything, allowed only on fields up to 120 cells a side. Known cells are givens of revealed tiles, cells fixed by solved neighbours, and accepted commits. With {agent} the observation also carries your health. The payload is under structuredContent.observation; cell id = y*G + x. Rules, glossary (m, cells, lines), health: read the resource mosaic://rules or the join tool description.",401 inputSchema: z.object({402 game_id: gameIdSchema,403 agent: z.string().min(1).max(80).optional(),404 rect: z.tuple([405 z.number().int().nonnegative(),406 z.number().int().nonnegative(),407 z.number().int().positive(),408 z.number().int().positive(),409 ]).optional(),410 full: z.boolean().optional(),411 }),412 annotations: { readOnlyHint: true },413 },414 ({ game_id, agent, rect, full }) => {415 try {416 const entry = getEntry(game_id);417 if (entry && "status" in entry && !("gameId" in entry)) {418 const secs = Math.round((Date.now() - entry.started) / 1000);419 return textResult(420 entry.status === "failed"421 ? `Game ${game_id}: generation failed (${entry.error}). Start another.`422 : `Game ${game_id} is generating (${secs}s so far). Poll again in 10-20 s; join once the status is playing.`,423 { observation: { game_id, status: entry.status, seconds: secs } },424 );425 }426 const ref = requireGame(game_id);427 const obs = ref.observe({ agent, rect, full });428 const solved = obs.tiles.filter((t) => t.status === "solved").length;429 return textResult(430 `Game ${game_id} is ${obs.status} at revision ${obs.revision}; ${solved}/${obs.tiles.length} listed tiles solved (${obs.tiles_total} in the field${431 obs.grown ? `, grown ${obs.grown}` : ""432 }${433 obs.fog ? ", fog" : ""434 }); reward ${obs.totals.reward}; ${obs.agents.length} agents; frontier ${435 JSON.stringify(obs.frontier)436 }.${437 obs.health438 ? ` Health ${obs.health.state} (${439 (obs.health.rate * 100).toFixed(1)440 }% wrong over ${obs.health.committed}).`441 : ""442 }${obs.note ? " " + obs.note : ""}`,443 { observation: obs },444 );445 } catch (error) {446 return errorResult(error);447 }448 },449 );450451 server.registerTool(452 "reveal",453 {454 title: "Reveal a tile",455 description:456 "Reveal the givens of a hidden tile on your frontier, by tile id. Returns the tile's shape (x0, y0, w, h, rects, cells), its givens, and any of its cells already known (fixed by solved neighbours), as [cell_id, value] pairs. Appends one batch event. Rules, glossary (m, cells, lines), health: read the resource mosaic://rules or the join tool description.",457 inputSchema: z.object({458 game_id: gameIdSchema,459 agent: z.string().min(1).max(80).describe("Your agent id from join."),460 expected_revision: z.number().int().nonnegative(),461 tile: z.number().int().nonnegative().describe("Tile id."),462 intent: z.string().max(160).default(""),463 }),464 annotations: { destructiveHint: false, idempotentHint: false },465 },466 async ({ game_id, agent, expected_revision, tile, intent }) => {467 try {468 const ref = requireGame(game_id);469 const r = await ref.reveal(expected_revision, tile, intent, agent);470 return textResult(471 `Revealed tile #${r.tile} (${r.cells} cells in a ${r.w}x${r.h} box at ${r.x0},${r.y0}): ${r.givens.length} givens, ${r.seam.length} cells already known. Revision ${r.revision}.`,472 { ...r },473 );474 } catch (error) {475 return errorResult(error);476 }477 },478 );479480 server.registerTool(481 "commit",482 {483 title: "Commit cells",484 description:485 "Commit a batch of [cell_id, value] pairs inside revealed tiles, with a one-line intent (<=160 chars) stating your reason; it is shown verbatim to spectators. Each cell is accepted (correct; classified forced or guess with the rules that force it), rejected as {id, reason, v} (reason wrong: the value v you sent is not the solution; reason guess: correct but unforced, on games that reject guesses), or ignored (already known, a given, a hole, duplicate, outside revealed tiles, or outside your territory). Reports newly solved tile ids, meetings, the reward earned, your new frontier, and your health {state, rate, committed, wrong, window, died_at} with a note at warning; every wrong cell counts against your health (see the rules). Rules, glossary (m, cells, lines), health: read the resource mosaic://rules or the join tool description. A batch is at most 512 cells; verdicts are given in order and stop at death: what was evaluated stands, the rest comes back as unevaluated with no judgement.",486 inputSchema: z.object({487 game_id: gameIdSchema,488 agent: z.string().min(1).max(80).describe("Your agent id from join."),489 expected_revision: z.number().int().nonnegative(),490 cells: z.array(491 z.tuple([492 z.number().int().nonnegative(),493 z.number().int().min(0).max(1),494 ]),495 ).min(1).max(512),496 intent: z.string().max(160).default(""),497 }),498 annotations: { destructiveHint: true, idempotentHint: false },499 },500 async ({ game_id, agent, expected_revision, cells, intent }) => {501 try {502 const ref = requireGame(game_id);503 const r = await ref.commit(504 expected_revision,505 cells as [number, number][],506 intent,507 agent,508 );509 const forced = r.accepted.filter((c) => c.class === "forced").length;510 const met = r.meets.length511 ? ` You met ${512 r.meets.map((m) => m.with).join(", ")513 }: territories merged.`514 : "";515 const text = r.status === "solved"516 ? `Field solved at revision ${r.revision}: ${r.accepted.length} accepted, reward ${r.reward}.${met}`517 : `Batch ${r.revision}: ${r.accepted.length} accepted (${forced} forced, ${518 r.accepted.length - forced519 } guess), ${r.rejected.length} rejected, ${r.ignored.length} ignored; solved ${520 JSON.stringify(r.solved)521 } for ${r.reward} reward; frontier ${522 JSON.stringify(r.frontier)523 }.${met} Health ${r.health.state} (${524 (r.health.rate * 100).toFixed(1)525 }% wrong over ${r.health.committed}).${r.note ? " " + r.note : ""}`;526 return textResult(text, { ...r });527 } catch (error) {528 return errorResult(error);529 }530 },531 );532533 server.registerTool(534 "retract",535 {536 title: "Retract commits",537 description:538 "Withdraw cells you committed earlier (by cell id). Givens and cells fixed by neighbours cannot be retracted. Counted by the analyzer. Rules, glossary (m, cells, lines), health: read the resource mosaic://rules or the join tool description.",539 inputSchema: z.object({540 game_id: gameIdSchema,541 agent: z.string().min(1).max(80).describe("Your agent id from join."),542 expected_revision: z.number().int().nonnegative(),543 cells: z.array(z.number().int().nonnegative()).min(1).max(8192),544 intent: z.string().max(160).default(""),545 }),546 annotations: { destructiveHint: true, idempotentHint: false },547 },548 async ({ game_id, agent, expected_revision, cells, intent }) => {549 try {550 const ref = requireGame(game_id);551 const r = await ref.retract(expected_revision, cells, intent, agent);552 return textResult(553 `Retracted ${r.retracted.length} cells. Revision ${r.revision}.`,554 { ...r },555 );556 } catch (error) {557 return errorResult(error);558 }559 },560 );561}