New scripts/build-mcpb.mjs packs a Claude Desktop extension bundle (scripts/mcpb-wrapper.mjs + manifest + icon) into dist-mcpb/tiletopia.mcpb. The wrapper reads the bearer token from %APPDATA% at launch and execs `npx -y mcp-remote`, so no secrets are baked in and Regenerate keeps working transparently. Run via `pnpm run build:mcpb`. McpPanel gets a "Download .mcpb" button linking to the releases page; the help-overlay tip and README MCP section both lead with the bundle install path and keep the .mcp.json shim recipe as the Claude Code fallback. Session-log entry in memory.md covers the design choices, especially why the wrapper-script approach beat the alternatives (user_config prompt would defeat one-click; baked-in token would be wrong for everyone else).
259 lines
9.6 KiB
JavaScript
259 lines
9.6 KiB
JavaScript
#!/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);
|
||
}
|