#!/usr/bin/env node // build-mcpb.mjs — package tiletopia's Claude Desktop MCP bundle. // // Produces dist-mcpb/tiletopia.mcpb — an .mcpb (MCP Bundle, the format // formerly known as DXT) zip containing: // manifest.json → declares a node-type server pointing at the wrapper // server/index.mjs → the wrapper script that reads %APPDATA% and // execs `npx -y mcp-remote ...` (see mcpb-wrapper.mjs) // icon.png → 128×128 brand icon // // Usage: // pnpm run build:mcpb // or // node scripts/build-mcpb.mjs // // Output: // dist-mcpb/tiletopia.mcpb — drag-and-drop this into Claude Desktop's // Extensions panel to install. // // Design notes: // - The bundle bakes in NO secrets. The bearer token + port are read at // runtime from %APPDATA%\com.megaproxy.tiletopia\mcp.json on the user's // own machine. Each install of tiletopia generates its own token; the // bundle is the same for everyone. // - We write the zip ourselves (store-only, no compression) to avoid a // devDep on archiver/jszip/etc. The MCPB spec is just a regular zip; // three small files = trivial. // - The manifest's `version` mirrors package.json so the panel UI can show // "Bundle v0.2.3 — matches running app". import { readFile, writeFile, mkdir, stat } from "node:fs/promises"; import { existsSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { deflateRawSync, crc32 } from "node:zlib"; const HERE = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = resolve(HERE, ".."); const PKG_PATH = join(REPO_ROOT, "package.json"); const WRAPPER_PATH = join(HERE, "mcpb-wrapper.mjs"); const ICON_PATH = join(REPO_ROOT, "src-tauri", "icons", "128x128.png"); const OUT_DIR = join(REPO_ROOT, "dist-mcpb"); const OUT_PATH = join(OUT_DIR, "tiletopia.mcpb"); // ---------------------------------------------------------------------------- // Read inputs // ---------------------------------------------------------------------------- if (!existsSync(WRAPPER_PATH)) { console.error(`missing wrapper: ${WRAPPER_PATH}`); process.exit(1); } if (!existsSync(ICON_PATH)) { console.error(`missing icon: ${ICON_PATH}`); process.exit(1); } const pkg = JSON.parse(await readFile(PKG_PATH, "utf8")); const wrapperSrc = await readFile(WRAPPER_PATH); const iconBytes = await readFile(ICON_PATH); // ---------------------------------------------------------------------------- // Manifest // // Schema reference: https://github.com/modelcontextprotocol/mcpb/blob/main/MANIFEST.md // // type=node + entry_point pointing at server/index.mjs + mcp_config.command // = "node" matches Claude Desktop's expectations. We avoid a `user_config` // block on purpose — the wrapper reads the token from %APPDATA% so the user // doesn't have to copy-paste it at install time. // ---------------------------------------------------------------------------- const manifest = { manifest_version: "0.3", name: "tiletopia", display_name: "tiletopia (workspace driver)", version: pkg.version, description: "Drive your tiletopia workspace from Claude Desktop — inspect panes, " + "read scrollback, reshape the layout, and (with policy approval) send " + "commands.", long_description: "tiletopia is a Windows tiling terminal manager for WSL. This bundle " + "lets Claude Desktop connect to a running tiletopia process on the same " + "machine via its embedded MCP server. The bundle reads the per-install " + "bearer token and port from %APPDATA%\\com.megaproxy.tiletopia\\mcp.json " + "at launch, so you don't need to paste any credentials during install. " + "Start the MCP server once from tiletopia's 🤖 panel (Server: ON), then " + "drop this bundle into Claude Desktop and it will connect automatically. " + "All write operations (spawn, write keystrokes, reshape) are gated by " + "the per-pane allow-list and the user-editable policy inside tiletopia.", author: { name: "megaproxy", url: "https://git.rdx4.com/megaproxy/tiletopia", }, repository: { type: "git", url: "https://git.rdx4.com/megaproxy/tiletopia.git", }, homepage: "https://git.rdx4.com/megaproxy/tiletopia", documentation: "https://git.rdx4.com/megaproxy/tiletopia#mcp-server-claude-can-drive-the-workspace", support: "https://git.rdx4.com/megaproxy/tiletopia/issues", icon: "icon.png", server: { type: "node", entry_point: "server/index.mjs", mcp_config: { command: "node", args: ["${__dirname}/server/index.mjs"], }, }, keywords: ["tiletopia", "wsl", "terminal", "mcp", "claude"], license: "Proprietary", compatibility: { // Claude Desktop runtime requirements — the bundle launches node, which // shells out to npx mcp-remote; both need Node 18+ on PATH. platforms: ["win32"], runtimes: { node: ">=18.0.0", }, }, }; const manifestBytes = Buffer.from(JSON.stringify(manifest, null, 2), "utf8"); // ---------------------------------------------------------------------------- // Build the .mcpb zip // // The MCPB spec is a plain ZIP file. We're writing three small files, so a // pure-Node store-only writer is simplest. Avoids adding archiver as a // devDep. Format reference: APPNOTE.TXT 6.3.4 sections 4.3 (local file // header), 4.4 (data descriptor), 4.5 (central directory). // ---------------------------------------------------------------------------- const SIG_LFH = 0x04034b50; const SIG_CDH = 0x02014b50; const SIG_EOCD = 0x06054b50; function dosTimeDate(date) { // DOS time/date format (2-second resolution; epoch 1980-01-01). const yr = Math.max(date.getFullYear(), 1980) - 1980; const time = ((date.getHours() & 0x1f) << 11) | ((date.getMinutes() & 0x3f) << 5) | ((Math.floor(date.getSeconds() / 2)) & 0x1f); const dt = ((yr & 0x7f) << 9) | (((date.getMonth() + 1) & 0x0f) << 5) | (date.getDate() & 0x1f); return { time, date: dt }; } function buildZip(entries) { const now = dosTimeDate(new Date()); const chunks = []; const centralDir = []; let offset = 0; for (const { name, data } of entries) { const nameBuf = Buffer.from(name, "utf8"); const crc = crc32(data); // store-only — uncompressed crc == compressed crc const size = data.length; // Local file header (4.3.7) const lfh = Buffer.alloc(30); lfh.writeUInt32LE(SIG_LFH, 0); lfh.writeUInt16LE(20, 4); // version needed (2.0) lfh.writeUInt16LE(0, 6); // general purpose bit flag lfh.writeUInt16LE(0, 8); // compression: store lfh.writeUInt16LE(now.time, 10); lfh.writeUInt16LE(now.date, 12); lfh.writeUInt32LE(crc, 14); lfh.writeUInt32LE(size, 18); // compressed size (== size for store) lfh.writeUInt32LE(size, 22); // uncompressed size lfh.writeUInt16LE(nameBuf.length, 26); lfh.writeUInt16LE(0, 28); // extra field length chunks.push(lfh, nameBuf, data); // Central directory header (4.4.7) const cdh = Buffer.alloc(46); cdh.writeUInt32LE(SIG_CDH, 0); cdh.writeUInt16LE(20, 4); // version made by cdh.writeUInt16LE(20, 6); // version needed cdh.writeUInt16LE(0, 8); // gp flag cdh.writeUInt16LE(0, 10); // compression cdh.writeUInt16LE(now.time, 12); cdh.writeUInt16LE(now.date, 14); cdh.writeUInt32LE(crc, 16); cdh.writeUInt32LE(size, 20); // compressed size cdh.writeUInt32LE(size, 24); // uncompressed cdh.writeUInt16LE(nameBuf.length, 28); cdh.writeUInt16LE(0, 30); // extra len cdh.writeUInt16LE(0, 32); // comment len cdh.writeUInt16LE(0, 34); // disk number cdh.writeUInt16LE(0, 36); // internal attrs cdh.writeUInt32LE(0, 38); // external attrs cdh.writeUInt32LE(offset, 42); // local header offset centralDir.push(cdh, nameBuf); offset += lfh.length + nameBuf.length + data.length; } const cdStart = offset; for (const buf of centralDir) { chunks.push(buf); offset += buf.length; } const cdSize = offset - cdStart; // End of central directory record (4.5) const eocd = Buffer.alloc(22); eocd.writeUInt32LE(SIG_EOCD, 0); eocd.writeUInt16LE(0, 4); // disk number eocd.writeUInt16LE(0, 6); // start disk eocd.writeUInt16LE(entries.length, 8); // entries on this disk eocd.writeUInt16LE(entries.length, 10); // total entries eocd.writeUInt32LE(cdSize, 12); eocd.writeUInt32LE(cdStart, 16); eocd.writeUInt16LE(0, 20); // comment length chunks.push(eocd); // Silence the "unused on store path" lint trip; deflateRawSync stays // imported so a future maintainer who wants to add compression doesn't // have to re-figure out the right symbol. void deflateRawSync; return Buffer.concat(chunks); } const entries = [ { name: "manifest.json", data: manifestBytes }, { name: "server/index.mjs", data: wrapperSrc }, { name: "icon.png", data: iconBytes }, ]; const zipBytes = buildZip(entries); await mkdir(OUT_DIR, { recursive: true }); await writeFile(OUT_PATH, zipBytes); const sizeKB = (zipBytes.length / 1024).toFixed(1); console.log(`wrote ${OUT_PATH} (${sizeKB} KB, ${entries.length} entries)`); for (const e of entries) { console.log(` ${e.name.padEnd(20)} ${e.data.length} bytes`); } console.log( `manifest version ${manifest.version} (mirrors package.json); ` + "to install, drag the .mcpb file into Claude Desktop's Extensions panel.", ); // Touch stat() so any "wrote nothing" CI bug surfaces here, not at the user's // next install. const written = await stat(OUT_PATH); if (written.size !== zipBytes.length) { console.error( `size mismatch: wrote ${zipBytes.length} bytes, file is ${written.size}`, ); process.exit(1); }