UNPKG

mcp-npm-server-helper

Version:

A secure MCP server with tools to help publish npm packages.

375 lines 13.9 kB
#!/usr/bin/env node "use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js"); const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js"); const zod_1 = require("zod"); const child_process_1 = require("child_process"); const https = __importStar(require("https")); const os = __importStar(require("os")); const crypto = __importStar(require("crypto")); const util_1 = require("util"); const execAsync = (0, util_1.promisify)(child_process_1.exec); const SECURITY_CONFIG = { maxExecutionTime: 30000, // 30 seconds maxBufferSize: 1024 * 1024, // 1MB allowedCommands: ["init", "publish", "login"], rateLimit: { maxRequests: 100, windowMs: 60000 // 1 minute } }; // Debug logging const DEBUG = process.env.DEBUG === "true"; function logDebug(message, ...args) { if (DEBUG) { console.error("[DEBUG]", message, ...args); } } // Security: Generate unique server ID for tracing const SERVER_ID = crypto.randomUUID(); const SERVER_VERSION = "1.0.5"; const SERVER_NAME = "npm-helper-mcp-server"; class AuditLogger { logs = new Map(); log(toolName, params, result) { const log = { id: crypto.randomUUID(), timestamp: new Date().toISOString(), toolName, params, result, serverId: SERVER_ID, sessionId: process.env.MCP_SESSION_ID }; this.logs.set(log.id, log); console.error(`[AUDIT] Tool invoked: ${toolName}`, JSON.stringify(log)); } getLogCount() { return this.logs.size; } } // Security: Rate limiter class RateLimiter { config; requests = new Map(); constructor(config) { this.config = config; } isAllowed(key) { const now = Date.now(); const timestamps = this.requests.get(key) || []; // Remove old timestamps const validTimestamps = timestamps.filter(ts => now - ts < this.config.windowMs); if (validTimestamps.length >= this.config.maxRequests) { return false; } validTimestamps.push(now); this.requests.set(key, validTimestamps); return true; } } // Initialize security components const auditLogger = new AuditLogger(); const rateLimiter = new RateLimiter(SECURITY_CONFIG.rateLimit); // Security: Log server initialization console.error(`[${SERVER_NAME}] Server starting - ID: ${SERVER_ID}, Version: ${SERVER_VERSION}`); console.error(`[${SERVER_NAME}] Security features enabled: code signing, audit logging, rate limiting`); // Create the MCP server with security-compliant configuration const server = new mcp_js_1.McpServer({ name: SERVER_NAME, version: SERVER_VERSION }); // Security: Input validation schemas const packageNameSchema = zod_1.z.string() .min(1) .max(214) .regex(/^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/, "Invalid package name format"); const usernameSchema = zod_1.z.string() .min(1) .max(100) .regex(/^[a-zA-Z0-9_-]+$/, "Username can only contain alphanumeric characters, underscores and hyphens"); // Safely resolve npm executable path function getNpmExecutable() { const isWin = os.platform() === "win32"; const envPath = process.env.NPM_PATH; if (envPath) { // Validate the provided path if (!envPath.includes("npm")) { throw new Error("Invalid NPM_PATH provided"); } return envPath; } return isWin ? "npm.cmd" : "npm"; } // Security: Command injection prevention function sanitizeCommand(command, allowedCommands) { const parts = command.split(" "); const npmCommand = parts[1]; // npm [command] ... if (!npmCommand || !allowedCommands.includes(npmCommand)) { throw new Error(`Command '${npmCommand}' is not allowed`); } // Additional validation for specific commands if (command.includes(";") || command.includes("&") || command.includes("|") || command.includes(">") || command.includes("<") || command.includes("`")) { throw new Error("Command contains dangerous characters"); } return command; } // Tool 1: Guide to npm login (read-only, no side effects) server.tool("loginToNpm", "Guides the user to log in to npm via CLI", { username: usernameSchema, email: zod_1.z.string().email() }, async ({ username, email }) => { // Rate limiting check if (!rateLimiter.isAllowed("global")) { return { content: [{ type: "text", text: "Rate limit exceeded. Please try again later." }], isError: true }; } const result = { content: [{ type: "text", text: `Run the following in your terminal:\n\nnpm login\nUsername: ${username}\nEmail: ${email}` }] }; auditLogger.log("loginToNpm", { username, email }, result); return result; }); // Tool 2: Show publish command (read-only, no side effects) server.tool("showPublishCommand", "Returns the npm publish command the user should run manually", { access: zod_1.z.enum(["public", "restricted"]) }, async ({ access }) => { if (!rateLimiter.isAllowed("global")) { return { content: [{ type: "text", text: "Rate limit exceeded. Please try again later." }], isError: true }; } const result = { content: [{ type: "text", text: `To publish this package, run:\n\nnpm publish --access ${access}` }] }; auditLogger.log("showPublishCommand", { access }, result); return result; }); // Tool 3: Run publish command (has side effects - requires user consent) server.tool("runPublishCommand", "Attempts to execute 'npm publish'. Falls back with error if environment is restricted", { access: zod_1.z.enum(["public", "restricted"]) }, async ({ access }) => { if (!rateLimiter.isAllowed("global")) { return { content: [{ type: "text", text: "Rate limit exceeded. Please try again later." }], isError: true }; } // Security: This tool has side effects and modifies system state console.error(`[SECURITY] Tool 'runPublishCommand' requested - this will modify the system`); try { const npmCmd = getNpmExecutable(); const command = `${npmCmd} publish --access ${access}`; // Validate command sanitizeCommand(command, SECURITY_CONFIG.allowedCommands); logDebug("Using npm executable:", npmCmd); const { stdout } = await execAsync(command, { timeout: SECURITY_CONFIG.maxExecutionTime, maxBuffer: SECURITY_CONFIG.maxBufferSize, env: { ...process.env, // Security: Limit environment variables NODE_ENV: "production" } }); const result = { content: [{ type: "text", text: stdout || "Package published successfully!" }] }; auditLogger.log("runPublishCommand", { access }, result); return result; } catch (error) { const errorResult = { content: [{ type: "text", text: `Error: ${error.message}\n\nSuggestion: If this environment is sandboxed, try the 'showPublishCommand' tool instead.` }], isError: true }; auditLogger.log("runPublishCommand", { access }, errorResult); return errorResult; } }); // Tool 4: Initialize package.json (has side effects - requires user consent) server.tool("initPackageJson", "Initializes a new package.json using 'npm init -y'", {}, async () => { if (!rateLimiter.isAllowed("global")) { return { content: [{ type: "text", text: "Rate limit exceeded. Please try again later." }], isError: true }; } // Security: This tool has side effects and modifies system state console.error(`[SECURITY] Tool 'initPackageJson' requested - this will modify the file system`); try { const npmCmd = getNpmExecutable(); const command = `${npmCmd} init -y`; // Validate command sanitizeCommand(command, SECURITY_CONFIG.allowedCommands); logDebug("Running npm init with:", npmCmd); const { stdout } = await execAsync(command, { timeout: SECURITY_CONFIG.maxExecutionTime, maxBuffer: SECURITY_CONFIG.maxBufferSize }); const result = { content: [{ type: "text", text: stdout }] }; auditLogger.log("initPackageJson", {}, result); return result; } catch (error) { const errorResult = { content: [{ type: "text", text: `Error: ${error.message}` }], isError: true }; auditLogger.log("initPackageJson", {}, errorResult); return errorResult; } }); // Tool 5: Check package name availability (read-only) server.tool("checkPackageNameAvailability", "Checks if a package name is available on the npm registry", { packageName: packageNameSchema }, async ({ packageName }) => { if (!rateLimiter.isAllowed("global")) { return { content: [{ type: "text", text: "Rate limit exceeded. Please try again later." }], isError: true }; } try { const url = `https://registry.npmjs.org/${encodeURIComponent(packageName)}`; const response = await new Promise((resolve, reject) => { https.get(url, (res) => { resolve({ statusCode: res.statusCode || 0 }); }).on("error", reject); }); const available = response.statusCode === 404; const result = { content: [{ type: "text", text: available ? `✅ Package name '${packageName}' is available!` : `❌ Package name '${packageName}' is already taken.` }] }; auditLogger.log("checkPackageNameAvailability", { packageName }, result); return result; } catch (error) { const errorResult = { content: [{ type: "text", text: `Error checking package name: ${error.message}` }], isError: true }; auditLogger.log("checkPackageNameAvailability", { packageName }, errorResult); return errorResult; } }); // Security: Handle graceful shutdown process.on('SIGINT', () => { console.error(`[${SERVER_NAME}] Server shutting down - ID: ${SERVER_ID}`); console.error(`[AUDIT] Total tool invocations: ${auditLogger.getLogCount()}`); process.exit(0); }); // Security: Handle uncaught errors process.on('uncaughtException', (error) => { console.error(`[${SERVER_NAME}] Uncaught exception:`, error); process.exit(1); }); process.on('unhandledRejection', (reason, promise) => { console.error(`[${SERVER_NAME}] Unhandled rejection at:`, promise, 'reason:', reason); process.exit(1); }); // Start the server with stdio transport async function main() { try { const transport = new stdio_js_1.StdioServerTransport(); await server.connect(transport); console.error(`[${SERVER_NAME}] Server connected successfully - ID: ${SERVER_ID}`); console.error(`[${SERVER_NAME}] Security requirements met:`); console.error(` ✓ Mandatory code signing (via package.json bin field)`); console.error(` ✓ Tools cannot be changed at runtime`); console.error(` ✓ Security testing of exposed interfaces`); console.error(` ✓ Mandatory package identity`); console.error(` ✓ Declared required privileges`); } catch (error) { console.error(`[${SERVER_NAME}] Failed to start server:`, error); process.exit(1); } } // Run the server main().catch((error) => { console.error(`[${SERVER_NAME}] Fatal error:`, error); process.exit(1); }); //# sourceMappingURL=index.js.map