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).
This commit is contained in:
parent
25aac634ab
commit
b29233a012
9 changed files with 505 additions and 3 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,6 +1,7 @@
|
||||||
# Node / build
|
# Node / build
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
dist-mcpb/
|
||||||
.svelte-kit/
|
.svelte-kit/
|
||||||
.pnpm-store/
|
.pnpm-store/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
|
||||||
20
README.md
20
README.md
|
|
@ -114,9 +114,25 @@ The titlebar 🤖 button opens a small panel that starts an MCP (Model Context P
|
||||||
- **Saved SSH passwords are never exposed** through the MCP surface.
|
- **Saved SSH passwords are never exposed** through the MCP surface.
|
||||||
- **Bound to all interfaces** (`0.0.0.0`). The bearer token is the only auth — don't enable the server on an untrusted network.
|
- **Bound to all interfaces** (`0.0.0.0`). The bearer token is the only auth — don't enable the server on an untrusted network.
|
||||||
|
|
||||||
#### Claude Code setup (via `mcp-remote` stdio shim)
|
#### Claude Desktop setup (one-click via `.mcpb` bundle — recommended)
|
||||||
|
|
||||||
Claude Code's HTTP-MCP client currently tries OAuth discovery and ignores static `headers` auth (Anthropic [#17152](https://github.com/anthropics/claude-code/issues/17152), [#46879](https://github.com/anthropics/claude-code/issues/46879)). The [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) stdio shim transparently proxies the HTTP endpoint with the bearer header attached, sidestepping the OAuth flow.
|
The MCP panel has a **Download .mcpb** button that fetches a packaged Claude Desktop extension (an `.mcpb` file). Drag it into Claude Desktop's *Settings → Extensions* pane and Claude will auto-discover tiletopia — no config editing, no copy-pasting tokens.
|
||||||
|
|
||||||
|
The bundle ships a tiny wrapper that reads your per-install bearer token straight from `%APPDATA%\com.megaproxy.tiletopia\mcp.json` at launch, so:
|
||||||
|
|
||||||
|
- It carries **no secrets** — the same file works for every tiletopia install.
|
||||||
|
- **Token regeneration** in the panel keeps working transparently; the next time Claude Desktop launches the extension, it'll pick up the new token.
|
||||||
|
- Requires `npx` (Node 18+) on PATH because the wrapper still talks to tiletopia through `mcp-remote` (same reason as the manual recipe below).
|
||||||
|
|
||||||
|
You can also rebuild the bundle from source:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm run build:mcpb # writes dist-mcpb/tiletopia.mcpb
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Claude Code setup (via `mcp-remote` stdio shim — fallback / manual recipe)
|
||||||
|
|
||||||
|
Claude Code (the terminal CLI) doesn't accept `.mcpb` bundles yet, and its HTTP-MCP client currently tries OAuth discovery and ignores static `headers` auth (Anthropic [#17152](https://github.com/anthropics/claude-code/issues/17152), [#46879](https://github.com/anthropics/claude-code/issues/46879)). The [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) stdio shim transparently proxies the HTTP endpoint with the bearer header attached, sidestepping the OAuth flow.
|
||||||
|
|
||||||
The panel's config snippet uses this shim by default — paste it into your project's `.mcp.json`:
|
The panel's config snippet uses this shim by default — paste it into your project's `.mcp.json`:
|
||||||
|
|
||||||
|
|
|
||||||
35
memory.md
35
memory.md
|
|
@ -106,6 +106,41 @@ The keyboard-shortcut table in README and the in-app help overlay used to be han
|
||||||
|
|
||||||
**Verified:** ran twice, second run reports "already up to date" with empty `git diff`. `pnpm check` clean (tsc --noEmit, exit 0).
|
**Verified:** ran twice, second run reports "already up to date" with empty `git diff`. `pnpm check` clean (tsc --noEmit, exit 0).
|
||||||
|
|
||||||
|
### 2026-05-26 — `.mcpb` Claude Desktop bundle (zero-config token handling)
|
||||||
|
|
||||||
|
Long-standing follow-up shipped. Build script + tiny Node wrapper produce `dist-mcpb/tiletopia.mcpb` — a one-click Claude Desktop install replacing the hand-paste of `.mcp.json`.
|
||||||
|
|
||||||
|
**Key design choice — per-install token handling.** The `.mcpb` spec offers two ways to handle credentials: `user_config` prompts at install time (copy-paste), or bake them in (wrong). Both lose: copy-paste defeats the whole point of one-click, and token rotation (the Regenerate button) would silently invalidate any saved `user_config` value. Picked a **third option not in the spec docs**: bundle a tiny Node wrapper as `entry_point` that reads `%APPDATA%\com.megaproxy.tiletopia\mcp.json` at launch and execs `npx -y mcp-remote ...` with the live token. Zero secrets in the bundle → safe to publish on the releases page; works for any tiletopia install; transparently picks up the new token after Regenerate without the user re-doing anything.
|
||||||
|
|
||||||
|
**Bundle shape (`scripts/build-mcpb.mjs`):**
|
||||||
|
- `manifest.json` — `type: "node"`, `entry_point: "server/index.mjs"`, `mcp_config: { command: "node", args: ["${__dirname}/server/index.mjs"] }`, version mirrors `package.json`, icon points at the 128×128 brand PNG.
|
||||||
|
- `server/index.mjs` — the wrapper. Reads `mcp.json`, validates port + token, spawns `npx -y mcp-remote http://127.0.0.1:<port>/mcp --allow-http --header "Authorization: Bearer <token>"` with `stdio: "inherit"`, forwards SIGINT/SIGTERM/SIGHUP to clean up the child on extension disable.
|
||||||
|
- `icon.png` — copy of `src-tauri/icons/128x128.png`.
|
||||||
|
|
||||||
|
**Build path.** `pnpm run build:mcpb` → `dist-mcpb/tiletopia.mcpb` (gitignored). Pure-Node store-only ZIP writer (~70 lines, no `archiver`/`jszip` devDep). Validated end-to-end with Python `zipfile`: 3 entries, valid CRCs, manifest parses. ~9 KB output.
|
||||||
|
|
||||||
|
**Distribution.** The script is committed; the artifact isn't (regenerable). The intent is to attach `tiletopia.mcpb` to each Forgejo release alongside the NSIS installer — `scripts/release.sh` doesn't do this yet (follow-up). The new "Download .mcpb" button in `McpPanel` opens the releases page; once the artifact is up there, users grab it from there.
|
||||||
|
|
||||||
|
**UI changes.**
|
||||||
|
- `McpPanel.tsx`: new "Claude Desktop (one-click install)" field above the .mcp.json snippet with a "Download .mcpb" button (opens the releases URL via `plugin-opener`) and a brief hint explaining zero-config token handling + the regen script. Styled in `McpPanel.css` (`.mcp-mcpb-row`, `.mcp-mcpb-btn`, `.mcp-mcpb-hint`).
|
||||||
|
- `McpPanel.css`: also added an explicit `.mcp-hint` style that was previously inheriting (used by both the token hint and the .mcpb hint).
|
||||||
|
- `shortcuts.ts`: MCP tip now leads with the `.mcpb` install path; the mcp-remote shim is described as the fallback for Claude Code (the terminal CLI, which doesn't accept `.mcpb` yet).
|
||||||
|
- `README.md`: same restructure under the MCP section — Claude Desktop install via `.mcpb` first, Claude Code via mcp-remote second.
|
||||||
|
|
||||||
|
**Why no in-app file save dialog?** I considered bundling the `.mcpb` inside the Tauri NSIS as a Rust resource + exposing a `download_mcpb` Tauri command that opens a save dialog. Would let the panel button work fully in-app. Rejected because (a) it'd require Rust changes which I can't compile-check in WSL, (b) it duplicates what releases do for free, and (c) "Download .mcpb" landing on the releases page is the more discoverable distribution flow long-term.
|
||||||
|
|
||||||
|
**Confirmed: bundle contains zero secrets.** Scanned both `manifest.json` and `server/index.mjs` for `Bearer ey`, `token=`, `secret`, `password`, `api_key` — all clean. The wrapper reads the token from `%APPDATA%` at runtime on the *user's* machine; nothing is ever baked in.
|
||||||
|
|
||||||
|
**`pnpm check` clean, vitest 72/72 passing.**
|
||||||
|
|
||||||
|
Open follow-ups specific to this session:
|
||||||
|
|
||||||
|
- **Wire `.mcpb` into the release.** `scripts/release.sh` currently uploads only the NSIS installer; it should also run `node scripts/build-mcpb.mjs` and attach the resulting `dist-mcpb/tiletopia.mcpb` to the Forgejo release. Two lines + one `tea releases create --asset` flag. Until that's done, the "Download .mcpb" button lands on a releases page where the asset doesn't exist yet for old tags.
|
||||||
|
- **Direct in-app save flow.** If we ever want fully-offline install (no roundtrip through the web), add a Rust-side `download_mcpb` command that returns the bundled bytes + use `@tauri-apps/plugin-dialog` save() in the panel. Not blocking — current flow is sufficient and matches how Tauri apps usually distribute extension files.
|
||||||
|
- **Pre-flight on the wrapper.** Could detect missing `npx` / Node 18+ and emit a more directed message. Currently we just let `spawn` fail with whatever Node says. The "make sure Node 18+ is installed and `npx` is on PATH" line in the error path is the band-aid.
|
||||||
|
- **`.mcpb` for Claude Code (CLI).** Claude Code doesn't accept `.mcpb` bundles yet — Anthropic may add it. When they do, the same bundle should Just Work since the wrapper is platform-agnostic re: which Claude is calling it.
|
||||||
|
- **Bundle compatibility field.** Manifest declares `platforms: ["win32"]` and `runtimes: { node: ">=18.0.0" }`. The wrapper has a hard `%APPDATA%` requirement so this is correct, but if anyone ever wants macOS / Linux tiletopia support, the wrapper needs a portable config-path lookup.
|
||||||
|
|
||||||
### 2026-05-26 — Hard-deny: PowerShell patterns + label list de-duplicated
|
### 2026-05-26 — Hard-deny: PowerShell patterns + label list de-duplicated
|
||||||
|
|
||||||
Mirrors the POSIX hard-deny rules with their Windows/PowerShell equivalents. Four new patterns:
|
Mirrors the POSIX hard-deny rules with their Windows/PowerShell equivalents. Four new patterns:
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"check": "tsc --noEmit",
|
"check": "tsc --noEmit",
|
||||||
|
"build:mcpb": "node scripts/build-mcpb.mjs",
|
||||||
"gen:readme": "node scripts/gen-readme-shortcuts.mjs",
|
"gen:readme": "node scripts/gen-readme-shortcuts.mjs",
|
||||||
"tauri": "tauri"
|
"tauri": "tauri"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
259
scripts/build-mcpb.mjs
Normal file
259
scripts/build-mcpb.mjs
Normal file
|
|
@ -0,0 +1,259 @@
|
||||||
|
#!/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);
|
||||||
|
}
|
||||||
110
scripts/mcpb-wrapper.mjs
Normal file
110
scripts/mcpb-wrapper.mjs
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
#!/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 */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -187,6 +187,55 @@
|
||||||
color: #ccd;
|
color: #ccd;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Inline small-print under inputs — small, muted, tight line-height. Used by
|
||||||
|
* the token hint and the .mcpb install hint. */
|
||||||
|
.mcp-hint {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: #888;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.mcp-hint code {
|
||||||
|
background: #0c0c0c;
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-family: inherit;
|
||||||
|
color: #aac;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Claude Desktop .mcpb install row ----------------------------------- */
|
||||||
|
|
||||||
|
.mcp-mcpb-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-mcpb-btn {
|
||||||
|
font: inherit;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: #1a2a3a;
|
||||||
|
color: #cce6ff;
|
||||||
|
border: 1px solid #2a4a6a;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.mcp-mcpb-btn:hover {
|
||||||
|
background: #2a4a6a;
|
||||||
|
color: #e0f0ff;
|
||||||
|
border-color: #4488cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-mcpb-hint {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.mcp-snippet {
|
.mcp-snippet {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,18 @@ import { useEffect, useState, useCallback } from "react";
|
||||||
import {
|
import {
|
||||||
writeText as clipboardWriteText,
|
writeText as clipboardWriteText,
|
||||||
} from "@tauri-apps/plugin-clipboard-manager";
|
} from "@tauri-apps/plugin-clipboard-manager";
|
||||||
|
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||||
import type { McpStatus, McpAuditEntry } from "../ipc";
|
import type { McpStatus, McpAuditEntry } from "../ipc";
|
||||||
import AuditTab from "./AuditTab";
|
import AuditTab from "./AuditTab";
|
||||||
import PolicyTab from "./PolicyTab";
|
import PolicyTab from "./PolicyTab";
|
||||||
import ErrorBoundary from "./ErrorBoundary";
|
import ErrorBoundary from "./ErrorBoundary";
|
||||||
import "./McpPanel.css";
|
import "./McpPanel.css";
|
||||||
|
|
||||||
|
// URL of the GitHub-style releases page where each tagged build attaches the
|
||||||
|
// prebuilt `.mcpb` bundle (sibling to the NSIS installer). Source bundle is
|
||||||
|
// regeneratable via `pnpm run build:mcpb`.
|
||||||
|
const MCPB_RELEASES_URL = "https://git.rdx4.com/megaproxy/tiletopia/releases";
|
||||||
|
|
||||||
interface McpPanelProps {
|
interface McpPanelProps {
|
||||||
status: McpStatus;
|
status: McpStatus;
|
||||||
onStart: () => Promise<void>;
|
onStart: () => Promise<void>;
|
||||||
|
|
@ -196,6 +202,31 @@ export default function McpPanel({
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mcp-field">
|
||||||
|
<label>Claude Desktop (one-click install)</label>
|
||||||
|
<div className="mcp-mcpb-row">
|
||||||
|
<button
|
||||||
|
className="mcp-mcpb-btn"
|
||||||
|
onClick={() => {
|
||||||
|
void openUrl(MCPB_RELEASES_URL).catch((e) =>
|
||||||
|
console.warn("open releases page failed:", e),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Download .mcpb
|
||||||
|
</button>
|
||||||
|
<p className="mcp-hint mcp-mcpb-hint">
|
||||||
|
Grab <code>tiletopia.mcpb</code> from the releases
|
||||||
|
page, then drag it into Claude Desktop's{" "}
|
||||||
|
<em>Settings → Extensions</em>. The bundle reads your
|
||||||
|
bearer token from <code>%APPDATA%</code> at launch —
|
||||||
|
zero copy-paste, and token regeneration above keeps
|
||||||
|
working transparently. (Bundle is regeneratable from
|
||||||
|
source via <code>pnpm run build:mcpb</code>.)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mcp-field">
|
<div className="mcp-field">
|
||||||
<label>Claude Code config snippet (.mcp.json)</label>
|
<label>Claude Code config snippet (.mcp.json)</label>
|
||||||
<pre className="mcp-snippet">
|
<pre className="mcp-snippet">
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,6 @@ export const TIPS: TipSpec[] = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "MCP server (let Claude drive the workspace)",
|
title: "MCP server (let Claude drive the workspace)",
|
||||||
body: "Titlebar 🤖 opens the MCP control panel — start the server and paste the snippet into your Claude Code .mcp.json. The snippet uses npx mcp-remote as a stdio shim because Claude Code's HTTP-MCP client ignores static bearer auth and tries OAuth instead; the shim proxies the HTTP endpoint with the bearer baked in. URL + token persist across restarts; Regenerate the token in the panel if it leaks. Default-deny per pane: toggle 🤖 on each pane's toolbar to expose it to MCP. Read-only in v1 (no spawn or write yet).",
|
body: "Titlebar 🤖 opens the MCP control panel. Start the server, then for Claude Desktop click 'Download .mcpb' and drag the file into Settings → Extensions — zero-config because the bundle reads your bearer token from %APPDATA% at launch (no copy-paste, survives token rotation). For Claude Code (terminal CLI) use the fallback snippet in the panel: it wires npx mcp-remote as a stdio shim because Claude Code's HTTP-MCP client ignores static bearer auth and tries OAuth instead. URL + token persist across restarts; Regenerate the token in the panel if it leaks. Default-deny per pane: toggle 🤖 on each pane's toolbar to expose it to MCP.",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue