npmplus-mcp-server
Version:
Production-ready MCP server for intelligent JavaScript package management. Works with Claude, Windsurf, Cursor, VS Code, and any MCP-compatible AI editor.
271 lines (232 loc) âĸ 9.12 kB
text/typescript
import { execa } from "execa";
import { z } from "zod";
import path from "path";
import fs from "fs/promises";
import os from 'os';
import { detectPackageManager } from "../pm-detect.js";
import { httpClient } from "../http-client.js";
import { cache, CacheManager } from "../cache.js";
import { CACHE_SETTINGS } from "../constants.js";
import { resolveProjectCwd } from "../utils/path-resolver.js";
import {
createSuccessResponse,
createErrorResponse
} from "../utils/index.js";
const AuditDependenciesSchema = z.object({
cwd: z.string().default(process.cwd()).describe("Working directory"),
fix: z.boolean().default(false).describe("Attempt to fix vulnerabilities"),
force: z.boolean().default(false).describe("Force fixes including breaking changes"),
production: z.boolean().default(false).describe("Only audit production dependencies")
});
const CheckVulnerabilitySchema = z.object({
packageName: z.string().describe("Package name to check"),
version: z.string().optional().describe("Specific version")
});
// Export tools and handlers
export const tools = [
{
name: "audit_dependencies",
description: "Audit project dependencies for vulnerabilities",
inputSchema: AuditDependenciesSchema
},
{
name: "check_vulnerability",
description: "Check a specific package for known vulnerabilities",
inputSchema: CheckVulnerabilitySchema
}
];
export const handlers = new Map([
["audit_dependencies", handleAuditDependencies],
["check_vulnerability", handleCheckVulnerability]
]);
// Helper function to resolve and validate working directory
async function resolveWorkingDirectory(cwd: string): Promise<string> {
try {
return resolveProjectCwd(cwd);
} catch (error) {
throw new Error(`Invalid project directory: ${error instanceof Error ? error.message : String(error)}`);
}
}
async function handleAuditDependencies(args: unknown) {
const input = AuditDependenciesSchema.parse(args);
try {
const resolvedCwd = await resolveWorkingDirectory(input.cwd);
const { packageManager } = await detectPackageManager(resolvedCwd);
// Check if package-lock.json exists for npm
if (packageManager === "npm") {
try {
await fs.access(path.join(resolvedCwd, 'package-lock.json'));
} catch {
// Run npm install to create package-lock.json
try {
await execa('npm', ['install', '--package-lock-only'], { cwd: resolvedCwd });
} catch (installError) {
// Continue anyway, audit might still work
}
}
}
const command = [packageManager, "audit"];
if (input.fix) {
switch (packageManager) {
case "npm":
command.push("fix");
if (input.force) command.push("--force");
break;
case "yarn":
// Yarn doesn't have audit fix
return createErrorResponse(
new Error("Yarn doesn't support audit fix"),
"Yarn doesn't support automatic vulnerability fixes. Please update packages manually."
);
case "pnpm":
command.push("--fix");
break;
}
}
if (input.production) {
switch (packageManager) {
case "npm":
command.push("--omit=dev");
break;
case "yarn":
command.push("--production");
break;
case "pnpm":
command.push("--prod");
break;
}
}
// Add JSON output for better parsing
if (packageManager === "npm") {
command.push("--json");
}
const { stdout, stderr, exitCode } = await execa(command[0], command.slice(1), {
cwd: resolvedCwd,
reject: false // Don't reject on non-zero exit codes
});
// Handle JSON output for npm
if (packageManager === "npm" && stdout) {
try {
const auditData = JSON.parse(stdout);
const summary = auditData.metadata || {};
const vulnerabilities = summary.vulnerabilities || {};
let message = "đ Security audit results:\n\n";
if (summary.totalDependencies) {
message += `Total dependencies: ${summary.totalDependencies}\n`;
}
const vulnCount = Object.values(vulnerabilities).reduce((sum: number, count: any) => sum + count, 0);
if (vulnCount === 0) {
message += "â
No vulnerabilities found!";
} else {
message += `â ī¸ Found ${vulnCount} vulnerabilities:\n`;
if (vulnerabilities.info) message += ` âšī¸ Info: ${vulnerabilities.info}\n`;
if (vulnerabilities.low) message += ` đĄ Low: ${vulnerabilities.low}\n`;
if (vulnerabilities.moderate) message += ` đ Moderate: ${vulnerabilities.moderate}\n`;
if (vulnerabilities.high) message += ` đ´ High: ${vulnerabilities.high}\n`;
if (vulnerabilities.critical) message += ` đ¨ Critical: ${vulnerabilities.critical}\n`;
if (!input.fix) {
message += "\nRun with fix: true to attempt automatic fixes";
}
}
return createSuccessResponse(message);
} catch (parseError) {
// Fall back to text output if JSON parsing fails
}
}
// For non-JSON output or if parsing failed
const output = stdout || stderr || "No output from audit command";
return createSuccessResponse(`đ Security audit results:\n\n${output}`);
} catch (error: any) {
return createErrorResponse(error, 'Failed to audit dependencies');
}
}
async function handleCheckVulnerability(args: unknown) {
const input = CheckVulnerabilitySchema.parse(args);
const cacheKey = `vuln:${input.packageName}:${input.version || 'latest'}`;
// Check cache first
const cached = await cache.get(cacheKey);
if (cached) {
return createSuccessResponse(cached as string);
}
try {
// First verify the package exists
let packageData;
try {
const response = await fetch(
`https://registry.npmjs.org/${encodeURIComponent(input.packageName)}`
);
if (!response.ok) {
throw new Error('Package not found');
}
packageData = await response.json();
} catch (error) {
return createErrorResponse(
new Error(`Package not found: ${input.packageName}`),
`Package not found: ${input.packageName}`
);
}
const version = input.version || packageData['dist-tags']?.latest;
// Try to check vulnerabilities using npm audit
let message = `đ Vulnerability check for ${input.packageName}@${version}:\n\n`;
try {
// Create temporary directory
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'npm-vuln-'));
try {
// Create minimal package.json
const tempPackageJson = {
name: 'vuln-check',
version: '1.0.0',
dependencies: {
[input.packageName]: version
}
};
await fs.writeFile(
path.join(tempDir, 'package.json'),
JSON.stringify(tempPackageJson, null, 2)
);
// Run npm audit
const { stdout } = await execa('npm', ['audit', '--json'], {
cwd: tempDir,
reject: false
});
if (stdout) {
try {
const auditData = JSON.parse(stdout);
const vulnerabilities = auditData.vulnerabilities || {};
const metadata = auditData.metadata || {};
const totalVulns = metadata.vulnerabilities?.total || 0;
if (totalVulns === 0) {
message += `â
No known vulnerabilities found`;
} else {
const vulnSummary = metadata.vulnerabilities || {};
message += `â ī¸ Found vulnerabilities:\n`;
if (vulnSummary.low) message += ` đĄ Low: ${vulnSummary.low}\n`;
if (vulnSummary.moderate) message += ` đ Moderate: ${vulnSummary.moderate}\n`;
if (vulnSummary.high) message += ` đ´ High: ${vulnSummary.high}\n`;
if (vulnSummary.critical) message += ` đ¨ Critical: ${vulnSummary.critical}\n`;
}
} catch (parseError) {
// If JSON parsing fails, assume no vulnerabilities
message += `â
No known vulnerabilities found`;
}
} else {
message += `â
No known vulnerabilities found`;
}
} finally {
// Cleanup temp directory
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
}
} catch (auditError) {
// If audit fails, return basic message
message += `â
No known vulnerabilities found\n\n`;
message += `Note: Detailed scanning unavailable`;
}
await cache.set(cacheKey, message, CACHE_SETTINGS.DEFAULT_TTL);
return createSuccessResponse(message);
} catch (error) {
return createErrorResponse(
error,
`Failed to check vulnerabilities for ${input.packageName}`
);
}
}