1import { createMcpHandler } from "@modelcontextprotocol/server";2import viewHtml from "../dist/mcp-app.html";3import { toInitialObservation } from "./game.ts";4import { LANDING_CSP, LANDING_SCRIPT, landingPage } from "./landing.ts";5import { deleteGamesInactiveBefore, findGameBySlug, listAllEvents } from "./repository.ts";6import { createServer } from "./server.ts";78const GAME_RETENTION_MS = 14 * 24 * 60 * 60 * 1_000;9const BAKED_REPLAY_SLOT = '"BAKED_REPLAY_SLOT"';1011const HTML_HEADERS = {12 "Content-Type": "text/html; charset=utf-8",13 "Cache-Control": "no-store",14 "X-Content-Type-Options": "nosniff",15 "Referrer-Policy": "no-referrer",16};1718async function handleReplay(env: Env, slug: string, download: boolean): Promise<Response> {19 const state = await findGameBySlug(env.DB, slug);20 if (state === null) {21 return new Response(22 `<!doctype html><html lang="en"><head><meta charset="UTF-8" /><title>MCPSweeper</title></head><body><main><h1>Replay not found</h1><p>Server copies expire after 14 days of inactivity. A downloaded replay file keeps working forever.</p></main></body></html>`,23 { status: 404, headers: HTML_HEADERS },24 );25 }26 const events = await listAllEvents(env.DB, state.id);27 const expiresAt = new Date(new Date(state.updatedAt).getTime() + GAME_RETENTION_MS).toISOString();28 const payload = {29 v: 1,30 start: toInitialObservation(state),31 events,32 meta: download ? { kind: "file" } : { kind: "server", slug, expires_at: expiresAt },33 };34 const baked = JSON.stringify(payload).replaceAll("<", "\\u003c");35 const html = viewHtml.replace(BAKED_REPLAY_SLOT, baked);36 return new Response(html, {37 headers: {38 ...HTML_HEADERS,39 ...(download ? { "Content-Disposition": `attachment; filename="mcpsweeper-replay-${slug}.html"` } : {}),40 },41 });42}4344function jsonError(message: string, status: number): Response {45 return Response.json({ error: message }, { status });46}4748function isAllowedLocalHost(request: Request): boolean {49 const hostname = new URL(request.url).hostname;50 if (hostname !== "localhost" && hostname !== "127.0.0.1" && hostname !== "[::1]") return true;51 const host = request.headers.get("Host")?.split(":")[0];52 return host === "localhost" || host === "127.0.0.1" || host === "[::1]";53}5455async function handleMcp(request: Request, env: Env): Promise<Response> {56 if (!isAllowedLocalHost(request)) return jsonError("Invalid Host header.", 403);57 const previewUrl = new URL("/preview", request.url).toString();58 const handler = createMcpHandler(() => createServer(env, viewHtml, previewUrl), {59 legacy: "stateless",60 responseMode: "json",61 onerror(error) {62 console.error(JSON.stringify({ message: "MCP request failed", error: error.message }));63 },64 });65 return handler.fetch(request);66}6768export default {69 async fetch(request: Request, env: Env): Promise<Response> {70 try {71 const url = new URL(request.url);72 if (url.pathname === "/mcp") return await handleMcp(request, env);73 if (request.method === "GET" && url.pathname === "/health") {74 return Response.json({ ok: true, service: "mcpsweeper" });75 }76 if (request.method === "GET" && url.pathname === "/") {77 return new Response(landingPage(request), {78 headers: {79 "Content-Type": "text/html; charset=utf-8",80 "Cache-Control": "no-store",81 "Content-Security-Policy": LANDING_CSP,82 "X-Content-Type-Options": "nosniff",83 "Referrer-Policy": "no-referrer",84 },85 });86 }87 if (request.method === "GET" && url.pathname === "/webmcp.js") {88 return new Response(LANDING_SCRIPT, {89 headers: {90 "Content-Type": "text/javascript; charset=utf-8",91 "Cache-Control": "no-store",92 "X-Content-Type-Options": "nosniff",93 },94 });95 }96 if (request.method === "GET") {97 const replayMatch = url.pathname.match(/^\/r\/([0-9a-f]{6,16})(\.html)?$/);98 if (replayMatch !== null) {99 return await handleReplay(env, replayMatch[1]!, replayMatch[2] === ".html");100 }101 }102 if (request.method === "GET" && url.pathname === "/preview") {103 return new Response(viewHtml, {104 headers: {105 "Content-Type": "text/html; charset=utf-8",106 "Cache-Control": "no-store",107 "X-Content-Type-Options": "nosniff",108 "Referrer-Policy": "no-referrer",109 },110 });111 }112 return jsonError("Not found.", 404);113 } catch (error) {114 const message = error instanceof Error ? error.message : "Unknown error";115 console.error(JSON.stringify({ message: "Unhandled Worker error", error: message }));116 return jsonError("Internal server error.", 500);117 }118 },119 async scheduled(controller: ScheduledController, env: Env): Promise<void> {120 const cutoff = new Date(controller.scheduledTime - GAME_RETENTION_MS).toISOString();121 const gamesDeleted = await deleteGamesInactiveBefore(env.DB, cutoff);122 console.log(JSON.stringify({ message: "Retention cleanup complete", cutoff, gamesDeleted }));123 },124} satisfies ExportedHandler<Env>;