tiletopia/scripts/build-mcpb.mjs
megaproxy b29233a012 Add .mcpb Claude Desktop bundle with zero-config token handling
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).
2026-05-26 17:36:29 +01:00

259 lines
9.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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);
}