#!/usr/bin/env node // tiletopia .mcpb wrapper — entry_point for the bundled MCP server. // // What this is: a thin stdio shim Claude Desktop launches when the user // installs `tiletopia.mcpb`. It reads the per-install MCP server settings // (port + bearer token) that the running tiletopia app persisted to // %APPDATA%\com.megaproxy.tiletopia\mcp.json, then execs `npx -y mcp-remote` // with the right URL + Authorization header. Claude talks stdio to us; we // proxy through mcp-remote, which talks HTTP to the tiletopia process. // // Why a wrapper (not just static args in the manifest): // - The bearer token is per-install — generated at first server start, also // rotated whenever the user clicks "Regenerate" in the MCP panel. We // can't bake it into the bundle (that'd be wrong for every other user) // and we don't want to make the user paste it into a user_config prompt // at install time. Reading it from %APPDATA% at launch makes the whole // thing zero-config and survives token rotation transparently. // - The port may also drift (if the saved port is taken, tiletopia falls // back to an OS-picked one and re-persists). Reading at launch keeps us // correct across that too. // // Failure modes & messages: every error we emit goes to stderr so the user // sees it in Claude Desktop's extension log. We deliberately do NOT swallow // or transform mcp-remote's own output beyond piping it. import { spawn } from "node:child_process"; import { readFileSync, existsSync } from "node:fs"; import { join } from "node:path"; const APPDATA = process.env.APPDATA; if (!APPDATA) { console.error( "[tiletopia-mcpb] %APPDATA% is unset — this bundle only runs on Windows.", ); process.exit(2); } const CFG_PATH = join(APPDATA, "com.megaproxy.tiletopia", "mcp.json"); if (!existsSync(CFG_PATH)) { console.error( `[tiletopia-mcpb] config not found at ${CFG_PATH}. ` + "Launch tiletopia, open the 🤖 MCP panel, and click Server: ON at least " + "once so the port + token get persisted, then retry.", ); process.exit(3); } let cfg; try { cfg = JSON.parse(readFileSync(CFG_PATH, "utf8")); } catch (e) { console.error(`[tiletopia-mcpb] failed to read/parse ${CFG_PATH}: ${e.message}`); process.exit(4); } const port = Number(cfg.port); const token = String(cfg.token ?? ""); if (!Number.isInteger(port) || port <= 0 || port > 65535 || !token) { console.error( `[tiletopia-mcpb] ${CFG_PATH} is missing a valid port or token. ` + "Toggle the MCP server off and on in the tiletopia panel to regenerate it.", ); process.exit(5); } const url = `http://127.0.0.1:${port}/mcp`; // `npx.cmd` on Windows is the actual launcher; bare `npx` is a shim that // node spawns from PATH and that's also fine. spawn() with shell:true ensures // PATHEXT resolution picks up the .cmd correctly. const child = spawn( "npx", [ "-y", "mcp-remote", url, "--allow-http", "--header", `Authorization: Bearer ${token}`, ], { stdio: "inherit", shell: true, }, ); child.on("error", (e) => { console.error( `[tiletopia-mcpb] failed to spawn npx: ${e.message}. ` + "Make sure Node.js 18+ is installed and `npx` is on PATH.", ); process.exit(6); }); child.on("exit", (code, signal) => { if (signal) process.kill(process.pid, signal); else process.exit(code ?? 0); }); // Forward terminate signals to the child so Claude Desktop's "disable // extension" cleans up the mcp-remote subprocess. for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) { process.on(sig, () => { try { child.kill(sig); } catch { /* child may already be gone */ } }); }