mcp-npm-server-helper
Version:
A secure MCP server with tools to help publish npm packages.
375 lines • 13.9 kB
JavaScript
;
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