npm-run-mcp-server
Version:
An MCP server that exposes package.json scripts as tools for agents.
312 lines (311 loc) • 11.6 kB
JavaScript
import { readFileSync, existsSync, watch } from 'fs';
import { promises as fsp } from 'fs';
import { dirname, resolve } from 'path';
import { fileURLToPath } from 'url';
import { exec as nodeExec } from 'child_process';
import { promisify } from 'util';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
const exec = promisify(nodeExec);
function parseCliArgs(argv) {
const args = {};
for (let i = 2; i < argv.length; i += 1) {
const token = argv[i];
if (!token)
continue;
if (token.startsWith('--')) {
const key = token.slice(2);
const next = argv[i + 1];
if (next && !next.startsWith('--')) {
args[key] = next;
i += 1;
}
else {
args[key] = true;
}
}
}
return args;
}
async function findNearestPackageJson(startDir) {
let current = resolve(startDir);
while (true) {
const candidate = resolve(current, 'package.json');
if (existsSync(candidate))
return candidate;
const parent = dirname(current);
if (parent === current)
break;
current = parent;
}
return null;
}
async function readPackageJson(pathToPackageJson) {
const raw = await fsp.readFile(pathToPackageJson, 'utf8');
return JSON.parse(raw);
}
function detectPackageManager(projectDir, pkg, override) {
if (override)
return override;
// Prefer explicit packageManager field if present
if (pkg.packageManager) {
const pm = pkg.packageManager.split('@')[0];
if (pm === 'npm' || pm === 'pnpm' || pm === 'yarn' || pm === 'bun')
return pm;
}
// Lockfile heuristic
if (existsSync(resolve(projectDir, 'pnpm-lock.yaml')))
return 'pnpm';
if (existsSync(resolve(projectDir, 'yarn.lock')))
return 'yarn';
if (existsSync(resolve(projectDir, 'bun.lockb')) || existsSync(resolve(projectDir, 'bun.lock')))
return 'bun';
return 'npm';
}
function buildRunCommand(pm, scriptName, extraArgs) {
const quoted = scriptName.replace(/"/g, '\\"');
const suffix = extraArgs && extraArgs.trim().length > 0 ? ` -- ${extraArgs}` : '';
switch (pm) {
case 'pnpm':
return `pnpm run "${quoted}"${suffix}`;
case 'yarn':
return `yarn run "${quoted}"${suffix}`;
case 'bun':
return `bun run "${quoted}"${suffix}`;
case 'npm':
default:
return `npm run "${quoted}"${suffix}`;
}
}
function trimOutput(out, limit = 12000) {
if (out.length <= limit)
return { text: out, truncated: false };
return { text: out.slice(0, limit) + `\n...[truncated ${out.length - limit} chars]`, truncated: true };
}
async function main() {
const args = parseCliArgs(process.argv);
// Try to detect workspace directory from environment variables
let startCwd = process.cwd(); // Initialize with fallback
if (args.cwd) {
startCwd = resolve(String(args.cwd));
}
else if (process.env.WORKSPACE_FOLDER_PATHS) {
// Cursor sets this as a semicolon-separated list, take the first one
const workspacePaths = process.env.WORKSPACE_FOLDER_PATHS.split(';');
let workspacePath = workspacePaths[0];
// Convert Windows path to WSL path if running in WSL
if (process.platform === 'linux' && workspacePath.match(/^[A-Za-z]:\\/)) {
// Convert H:\path\to\project to /mnt/h/path/to/project
const drive = workspacePath[0].toLowerCase();
const path = workspacePath.slice(3).replace(/\\/g, '/');
workspacePath = `/mnt/${drive}${path}`;
}
startCwd = workspacePath;
}
else if (process.env.VSCODE_WORKSPACE_FOLDER) {
startCwd = process.env.VSCODE_WORKSPACE_FOLDER;
}
else if (process.env.CURSOR_WORKSPACE_FOLDER) {
startCwd = process.env.CURSOR_WORKSPACE_FOLDER;
}
else {
// Fallback: try to find a workspace by looking for common patterns
const currentDir = process.cwd();
// If we're in the MCP server directory, try to find a parent directory with package.json
if (currentDir.includes('npm-run-mcp-server')) {
// Try going up directories to find a workspace
let testDir = dirname(currentDir);
let foundWorkspace = false;
for (let i = 0; i < 5; i++) {
const testPkgJson = resolve(testDir, 'package.json');
if (existsSync(testPkgJson)) {
startCwd = testDir;
foundWorkspace = true;
break;
}
testDir = dirname(testDir);
}
if (!foundWorkspace) {
startCwd = currentDir;
}
}
else {
startCwd = currentDir;
}
}
const pkgJsonPath = await findNearestPackageJson(startCwd);
let projectDir = null;
let projectPkg = null;
if (!pkgJsonPath) {
console.error(`npm-run-mcp-server: No package.json found starting from ${startCwd}`);
// Don't exit - start server with no tools instead
}
else {
projectDir = dirname(pkgJsonPath);
projectPkg = await readPackageJson(pkgJsonPath);
}
const verbose = Boolean(args.verbose ||
process.env.MCP_VERBOSE ||
(process.env.DEBUG && process.env.DEBUG.toLowerCase().includes('mcp')));
if (verbose) {
console.error(`[mcp] server starting: cwd=${startCwd}`);
console.error(`[mcp] detected workspace: ${process.env.VSCODE_WORKSPACE_FOLDER || process.env.CURSOR_WORKSPACE_FOLDER || 'none'}`);
if (pkgJsonPath) {
console.error(`[mcp] using package.json: ${pkgJsonPath}`);
}
else {
console.error(`[mcp] no package.json found - starting with no tools`);
}
}
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const selfPkgPath = resolve(__dirname, '..', 'package.json');
let serverName = 'npm-run-mcp-server';
let serverVersion = '0.0.0';
try {
if (existsSync(selfPkgPath)) {
const selfPkg = JSON.parse(readFileSync(selfPkgPath, 'utf8'));
if (selfPkg.name)
serverName = selfPkg.name;
if (selfPkg.version)
serverVersion = selfPkg.version;
if (verbose) {
console.error(`[mcp] loaded server info: ${serverName}@${serverVersion}`);
}
}
else {
if (verbose) {
console.error(`[mcp] package.json not found at: ${selfPkgPath}`);
}
}
}
catch (error) {
if (verbose) {
console.error(`[mcp] error reading package.json:`, error);
}
}
const server = new McpServer({ name: serverName, version: serverVersion });
// Handle case where no package.json was found
if (!projectDir || !projectPkg) {
if (args['list-scripts']) {
console.error('No package.json found - no scripts available');
process.exit(0);
}
const transport = new StdioServerTransport();
if (verbose) {
console.error(`[mcp] no tools registered; awaiting stdio client...`);
}
await server.connect(transport);
if (verbose) {
console.error(`[mcp] stdio transport connected (waiting for initialize)`);
}
return;
}
const pm = detectPackageManager(projectDir, projectPkg, args.pm);
if (verbose) {
console.error(`[mcp] detected package manager: ${pm}`);
}
const scripts = projectPkg.scripts ?? {};
const scriptNames = Object.keys(scripts);
if (scriptNames.length === 0) {
console.error(`npm-run-mcp-server: No scripts found in ${pkgJsonPath}`);
}
if (args['list-scripts']) {
for (const name of scriptNames) {
console.error(`${name}: ${scripts[name]}`);
}
process.exit(0);
}
// Register a tool per script
for (const scriptName of scriptNames) {
// Sanitize tool name - MCP tools can only contain [a-z0-9_-]
const toolName = scriptName.toLowerCase().replace(/[^a-z0-9_-]/g, '_');
// Create a more descriptive description
const scriptCommand = scripts[scriptName];
const description = `Run npm script "${scriptName}": ${scriptCommand}`;
server.tool(toolName, description, {
inputSchema: {
type: 'object',
properties: {
args: {
type: 'string',
description: 'Optional arguments appended after -- to the script'
}
}
},
}, async ({ args: extraArgs }) => {
const command = buildRunCommand(pm, scriptName, extraArgs);
try {
const { stdout, stderr } = await exec(command, {
cwd: projectDir,
env: process.env,
maxBuffer: 16 * 1024 * 1024, // 16MB
windowsHide: true,
});
const combined = stdout && stderr ? `${stdout}\n${stderr}` : stdout || stderr || '';
const { text } = trimOutput(combined);
return {
content: [
{
type: 'text',
text,
},
],
};
}
catch (error) {
const stdout = error?.stdout ?? '';
const stderr = error?.stderr ?? '';
const message = error?.message ? String(error.message) : 'Script failed';
const combined = [message, stdout, stderr].filter(Boolean).join('\n');
const { text } = trimOutput(combined);
return {
content: [
{
type: 'text',
text,
},
],
};
}
});
}
const transport = new StdioServerTransport();
if (verbose) {
console.error(`[mcp] registered ${scriptNames.length} tools; awaiting stdio client...`);
}
await server.connect(transport);
if (verbose) {
console.error(`[mcp] stdio transport connected (waiting for initialize)`);
}
// Set up file watcher for package.json changes
if (pkgJsonPath) {
if (verbose) {
console.error(`[mcp] setting up file watcher for: ${pkgJsonPath}`);
}
const watcher = watch(pkgJsonPath, (eventType) => {
if (eventType === 'change') {
if (verbose) {
console.error(`[mcp] package.json changed, restarting server...`);
}
// Gracefully exit to allow the MCP client to restart the server
process.exit(0);
}
});
// Handle cleanup on process exit
process.on('SIGINT', () => {
watcher.close();
process.exit(0);
});
process.on('SIGTERM', () => {
watcher.close();
process.exit(0);
});
}
}
// Run
main().catch((err) => {
console.error(err);
process.exit(1);
});