UNPKG

npm-run-mcp-server

Version:

An MCP server that exposes package.json scripts as tools for agents.

312 lines (311 loc) 11.6 kB
#!/usr/bin/env node 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); });