Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ DEV_LOG.md

# Build cache
.build-cache/
.bundle-resources/

# Temp directories
.tmp/
Expand Down
14 changes: 7 additions & 7 deletions electron-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,11 @@ files:
- "!**/.eslintrc*"
- "!**/tsconfig.json"

# MCP servers (compiled JavaScript files, cross-platform)
# NOTE: Platform-specific extraResources OVERRIDE this global section,
# so dist-mcp must also be listed in each platform's extraResources.
# MCP servers are staged into .bundle-resources/mcp after build:mcp completes.
# Packaging from the staged directory is more reliable on Windows than reading
# directly from freshly emitted dist-mcp files.
extraResources:
- from: dist-mcp
- from: .bundle-resources/mcp
to: mcp

# Unpack skills so they can be read/written at runtime
Expand All @@ -75,7 +75,7 @@ win:
artifactName: ${productName}-${version}-win-${arch}.${ext}
signAndEditExecutable: false
extraResources:
- from: dist-mcp
- from: .bundle-resources/mcp
to: mcp
- from: resources/node/win32-x64
to: node
Expand All @@ -99,7 +99,7 @@ mac:
NSScreenCaptureUsageDescription: "Open Cowork needs screen recording access for GUI automation."
NSAccessibilityUsageDescription: "Open Cowork needs accessibility access for GUI automation."
extraResources:
- from: dist-mcp
- from: .bundle-resources/mcp
to: mcp
- from: resources/node/darwin-${arch}
to: node
Expand All @@ -120,7 +120,7 @@ linux:
- x64
artifactName: ${productName}-${version}-linux-${arch}.${ext}
extraResources:
- from: dist-mcp
- from: .bundle-resources/mcp
to: mcp
- from: resources/node/linux-x64
to: node
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"bench:api": "node scripts/bench-api.mjs",
"rebuild": "node -e \"const {execSync}=require('node:child_process'); const v=require('electron/package.json').version; execSync('npm rebuild better-sqlite3 --runtime=electron --target='+v+' --disturl=https://electronjs.org/headers',{stdio:'inherit'})\"",
"typecheck": "tsc --noEmit",
"clean": "rimraf dist dist-electron dist-mcp dist-wsl-agent dist-lima-agent release",
"clean": "rimraf dist dist-electron dist-mcp dist-wsl-agent dist-lima-agent .bundle-resources release",
"deploy:local": "bash scripts/deploy-local.sh"
},
"dependencies": {
Expand Down
218 changes: 199 additions & 19 deletions scripts/bundle-mcp.js
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
/**
* Build MCP server TypeScript files into self-contained CommonJS bundles.
*
* Uses esbuild to bundle all dependencies (e.g. @modelcontextprotocol/sdk)
* into a single file per server, so the output can run standalone from
* Resources/mcp/ without needing a node_modules directory.
* Uses esbuild to bundle all dependencies into a single file per server so the
* packaged app can run MCP servers from Resources/mcp/ without shipping a
* node_modules directory next to them.
*
* Falls back to TypeScript transpile-only if esbuild is not available
* (e.g. locked-down Windows environments).
* On Windows, freshly generated bundle files can remain transiently locked for
* a short time. To make packaging more reliable, this script also stages the
* built files into a separate directory that electron-builder can copy from.
*/

const path = require('path');
const fs = require('fs');
const path = require('path');

const PROJECT_ROOT = path.join(__dirname, '..');
const SRC_MCP_DIR = path.join(PROJECT_ROOT, 'src', 'main', 'mcp');
const DIST_MCP_DIR = path.join(PROJECT_ROOT, 'dist-mcp');
const STAGED_MCP_DIR = path.join(PROJECT_ROOT, '.bundle-resources', 'mcp');
const FILE_OP_RETRY_COUNT = 8;
const FILE_OP_RETRY_DELAY_MS = 250;

const servers = [
{
Expand All @@ -29,7 +33,6 @@ const servers = [
},
];

// Node built-ins that should NOT be bundled
const NODE_EXTERNALS = [
'child_process',
'crypto',
Expand Down Expand Up @@ -74,6 +77,146 @@ function ensureDir(dir) {
}
}

function removePathIfExists(targetPath) {
if (fs.existsSync(targetPath)) {
fs.rmSync(targetPath, { recursive: true, force: true });
}
}

function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

function isRetryableFileOpError(error) {
return Boolean(
error &&
(error.code === 'EBUSY' || error.code === 'EPERM' || error.code === 'ENOTEMPTY')
);
}

async function removePathWithRetries(targetPath) {
let lastError = null;

for (let attempt = 1; attempt <= FILE_OP_RETRY_COUNT; attempt += 1) {
try {
if (!fs.existsSync(targetPath)) {
return;
}

fs.rmSync(targetPath, { recursive: true, force: true });
if (!fs.existsSync(targetPath)) {
return;
}

const error = new Error(`Path still exists after removal: ${targetPath}`);
error.code = 'ENOTEMPTY';
throw error;
} catch (error) {
lastError = error;
if (!isRetryableFileOpError(error) || attempt === FILE_OP_RETRY_COUNT) {
throw error;
}

console.warn(
`[bundle:mcp] Remove retry ${attempt}/${FILE_OP_RETRY_COUNT} for ${path.basename(targetPath)}: ${error.code}`
);
await sleep(FILE_OP_RETRY_DELAY_MS);
}
}

if (lastError) {
throw lastError;
}
}

async function copyFileWithRetries(sourcePath, destinationPath) {
let lastError = null;

for (let attempt = 1; attempt <= FILE_OP_RETRY_COUNT; attempt += 1) {
try {
fs.copyFileSync(sourcePath, destinationPath);
return;
} catch (error) {
lastError = error;
if (!isRetryableFileOpError(error) || attempt === FILE_OP_RETRY_COUNT) {
throw error;
}

console.warn(
`[bundle:mcp] Copy retry ${attempt}/${FILE_OP_RETRY_COUNT} for ${path.basename(sourcePath)}: ${error.code}`
);
await sleep(FILE_OP_RETRY_DELAY_MS);
}
}

if (lastError) {
throw lastError;
}
}

async function copyDirectoryContentsWithRetries(sourceDir, destinationDir) {
fs.mkdirSync(destinationDir, { recursive: true });

const sourceEntries = fs.readdirSync(sourceDir, { withFileTypes: true });
const sourceEntryNames = new Set(sourceEntries.map((entry) => entry.name));
const destinationEntries = fs.readdirSync(destinationDir, { withFileTypes: true });

for (const entry of destinationEntries) {
if (!sourceEntryNames.has(entry.name)) {
await removePathWithRetries(path.join(destinationDir, entry.name));
}
}

for (const entry of sourceEntries) {
const sourcePath = path.join(sourceDir, entry.name);
const destinationPath = path.join(destinationDir, entry.name);

if (entry.isDirectory()) {
await copyDirectoryContentsWithRetries(sourcePath, destinationPath);
continue;
}

await copyFileWithRetries(sourcePath, destinationPath);
}
}

async function replaceDirectoryWithRetries(sourceDir, destinationDir) {
let lastError = null;

for (let attempt = 1; attempt <= FILE_OP_RETRY_COUNT; attempt += 1) {
try {
await removePathWithRetries(destinationDir);
fs.renameSync(sourceDir, destinationDir);
return;
} catch (error) {
if (!fs.existsSync(sourceDir) && fs.existsSync(destinationDir)) {
return;
}

lastError = error;
if (!isRetryableFileOpError(error)) {
throw error;
}
if (attempt === FILE_OP_RETRY_COUNT) {
break;
}

console.warn(
`[bundle:mcp] Directory swap retry ${attempt}/${FILE_OP_RETRY_COUNT} for ${path.basename(destinationDir)}: ${error.code}`
);
await sleep(FILE_OP_RETRY_DELAY_MS);
}
}

if (lastError) {
console.warn(
`[bundle:mcp] Falling back to copy-based directory swap for ${path.basename(destinationDir)}`
);
await copyDirectoryContentsWithRetries(sourceDir, destinationDir);
removePathIfExists(sourceDir);
}
}

async function bundleWithEsbuild() {
const esbuild = require('esbuild');

Expand All @@ -96,7 +239,7 @@ async function bundleWithEsbuild() {

const stats = fs.statSync(outfile);
const sizeKB = (stats.size / 1024).toFixed(2);
console.log(`📦 ${server.description}`);
console.log(`Built ${server.description}`);
console.log(` Entry: ${server.entry}`);
console.log(` Output: dist-mcp/${server.name}.js (${sizeKB} KB, bundled)`);
}
Expand All @@ -105,8 +248,8 @@ async function bundleWithEsbuild() {
function transpileFallback() {
const ts = require('typescript');

console.log('⚠️ esbuild unavailable, falling back to TypeScript transpile-only');
console.log(' Dependencies will NOT be bundled MCP servers may fail in packaged builds.\n');
console.log('esbuild unavailable, falling back to TypeScript transpile-only');
console.log('Dependencies will NOT be bundled. MCP servers may fail in packaged builds.\n');

const sourceFiles = fs.readdirSync(SRC_MCP_DIR).filter((file) => file.endsWith('.ts'));

Expand All @@ -129,11 +272,13 @@ function transpileFallback() {

if (result.diagnostics?.length) {
const errors = result.diagnostics.filter(
(d) => d.category === ts.DiagnosticCategory.Error
(diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error
);
if (errors.length > 0) {
throw new Error(
`${file}\n${errors.map((d) => ts.flattenDiagnosticMessageText(d.messageText, '\n')).join('\n')}`
`${file}\n${errors
.map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'))
.join('\n')}`
);
}
}
Expand All @@ -145,14 +290,40 @@ function transpileFallback() {
const outfile = path.join(DIST_MCP_DIR, `${server.name}.js`);
const stats = fs.statSync(outfile);
const sizeKB = (stats.size / 1024).toFixed(2);
console.log(`📦 ${server.description}`);
console.log(`Built ${server.description}`);
console.log(` Entry: ${server.entry}`);
console.log(` Output: dist-mcp/${server.name}.js (${sizeKB} KB, transpile-only)`);
}
}

async function stageBundledServers(
sourceDir = DIST_MCP_DIR,
stagedDir = STAGED_MCP_DIR,
serverList = servers
) {
const stageRoot = path.dirname(stagedDir);
const tempDir = path.join(stageRoot, `mcp.tmp-${process.pid}-${Date.now()}`);

ensureDir(stageRoot);
removePathIfExists(tempDir);
fs.mkdirSync(tempDir, { recursive: true });

try {
for (const server of serverList) {
const filename = `${server.name}.js`;
await copyFileWithRetries(path.join(sourceDir, filename), path.join(tempDir, filename));
}

await replaceDirectoryWithRetries(tempDir, stagedDir);
console.log(`[bundle:mcp] Staged bundled MCP servers at ${path.relative(PROJECT_ROOT, stagedDir)}`);
} catch (error) {
removePathIfExists(tempDir);
throw error;
}
}

async function bundleMCPServers() {
console.log('🔨 Building MCP Servers...\n');
console.log('Building MCP Servers...\n');
ensureDir(DIST_MCP_DIR);

try {
Expand All @@ -162,10 +333,19 @@ async function bundleMCPServers() {
transpileFallback();
}

console.log('\n✅ All MCP servers built successfully!\n');
await stageBundledServers();

console.log('\nAll MCP servers built successfully!\n');
}

bundleMCPServers().catch((error) => {
console.error('❌ Bundle failed:', error?.stack || error);
process.exit(1);
});
module.exports = {
bundleMCPServers,
stageBundledServers,
};

if (require.main === module) {
bundleMCPServers().catch((error) => {
console.error('Bundle failed:', error?.stack || error);
process.exit(1);
});
}
Loading
Loading