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.
374 lines (311 loc) • 12.8 kB
text/typescript
import { execa } from "execa";
import { z } from "zod";
import path from "path";
import fs from "fs/promises";
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 DependencyTreeSchema = z.object({
cwd: z.string().default(process.cwd()).describe("Working directory"),
depth: z.number().default(3).describe("Maximum depth of tree"),
production: z.boolean().default(false).describe("Only show production dependencies")
});
const BundleSizeSchema = z.object({
packageName: z.string().describe("Package name to analyze"),
version: z.string().optional().describe("Specific version (default: latest)")
});
const AnalyzeDependenciesSchema = z.object({
cwd: z.string().default(process.cwd()).describe("Working directory"),
circular: z.boolean().default(true).describe("Check for circular dependencies"),
orphans: z.boolean().default(true).describe("Check for orphaned files")
});
const DownloadStatsSchema = z.object({
packageName: z.string().describe("Package name"),
period: z.enum(["last-day", "last-week", "last-month", "last-year"])
.default("last-month")
.describe("Time period for statistics")
});
// Export tools and handlers
export const tools = [
{
name: "dependency_tree",
description: "Display the dependency tree of a project",
inputSchema: DependencyTreeSchema
},
{
name: "check_bundle_size",
description: "Check the bundle size of a package before installing",
inputSchema: BundleSizeSchema
},
{
name: "analyze_dependencies",
description: "Analyze project dependencies for issues like circular dependencies",
inputSchema: AnalyzeDependenciesSchema
},
{
name: "download_stats",
description: "Get download statistics for a package",
inputSchema: DownloadStatsSchema
}
];
export const handlers = new Map([
["dependency_tree", handleDependencyTree],
["check_bundle_size", handleBundleSize],
["analyze_dependencies", handleAnalyzeDependencies],
["download_stats", handleDownloadStats]
]);
// 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 handleDependencyTree(args: unknown) {
const input = DependencyTreeSchema.parse(args);
try {
const resolvedCwd = await resolveWorkingDirectory(input.cwd);
const { packageManager } = await detectPackageManager(resolvedCwd);
const command = [packageManager, "list"];
// Add depth flag
switch (packageManager) {
case "npm":
command.push(`--depth=${input.depth}`);
if (input.production) command.push("--omit=dev");
break;
case "yarn":
command.push(`--depth=${input.depth}`);
if (input.production) command.push("--production");
break;
case "pnpm":
command.push(`--depth=${input.depth}`);
if (input.production) command.push("--prod");
break;
}
const { stdout, stderr } = await execa(command[0], command.slice(1), {
cwd: resolvedCwd,
reject: false
});
const output = stdout || stderr || "Unable to generate dependency tree";
// Check if output is empty or just shows the root package
if (output.trim().split('\n').length <= 3) {
// Try alternative command for npm
if (packageManager === "npm") {
const altResult = await execa('npm', ['ls', '--all'], {
cwd: resolvedCwd,
reject: false
});
if (altResult.stdout) {
return createSuccessResponse(`Dependency tree (depth: ${input.depth}):\n\n${altResult.stdout}`);
}
}
return createSuccessResponse(`Dependency tree (depth: ${input.depth}):\n\n${output}\n\nNote: This project may have no dependencies or the tree may be empty.`);
}
return createSuccessResponse(`Dependency tree (depth: ${input.depth}):\n\n${output}`);
} catch (error: any) {
return createErrorResponse(error, 'Failed to generate dependency tree');
}
}
async function handleBundleSize(args: unknown) {
const input = BundleSizeSchema.parse(args);
const cacheKey = `bundle:${input.packageName}:${input.version || 'latest'}`;
// Check cache first
const cached = await cache.get(cacheKey);
if (cached) {
return createSuccessResponse(cached as string);
}
try {
// Try bundlephobia API first
const bundlephobiaUrl = `https://bundlephobia.com/api/size?package=${encodeURIComponent(input.packageName)}${input.version ? `@${input.version}` : ''}`;
try {
const response = await fetch(bundlephobiaUrl, {
headers: {
'User-Agent': 'npmplus-mcp-server'
}
});
if (response.ok) {
const data = await response.json();
const formatSize = (bytes: number) => {
const kb = bytes / 1024;
return kb > 1024 ? `${(kb / 1024).toFixed(2)} MB` : `${kb.toFixed(2)} KB`;
};
let message = `📦 Bundle Size Analysis for ${input.packageName}@${data.version}:\n\n`;
message += `📏 Minified: ${formatSize(data.size)}\n`;
message += `🗜️ Gzipped: ${formatSize(data.gzip)}\n`;
if (data.hasJSModule !== undefined) {
message += `📦 ES Modules: ${data.hasJSModule ? '✅ Yes' : '❌ No'}\n`;
}
if (data.hasSideEffects !== undefined) {
message += `⚡ Side Effects: ${data.hasSideEffects ? '⚠️ Yes' : '✅ No'}\n`;
}
await cache.set(cacheKey, message, CACHE_SETTINGS.LONG_TTL);
return createSuccessResponse(message);
}
} catch (bundlephobiaError) {
// Fall back to npm registry data
}
// Fallback to npm registry
const packageUrl = `https://registry.npmjs.org/${encodeURIComponent(input.packageName)}`;
const packageData = await httpClient.npmRegistry(encodeURIComponent(input.packageName));
if (!packageData) {
return createErrorResponse(
new Error(`Package not found: ${input.packageName}`),
`Package not found: ${input.packageName}`
);
}
const version = input.version || (packageData as any)['dist-tags']?.latest;
const versionData = (packageData as any).versions?.[version];
if (!versionData) {
return createErrorResponse(
new Error(`Version not found: ${input.packageName}@${input.version}`),
`Version not found: ${input.packageName}@${input.version}`
);
}
const dist = versionData.dist || {};
let message = `📦 Bundle Size Analysis for ${input.packageName}@${version}:\n\n`;
if (dist.unpackedSize) {
const kb = dist.unpackedSize / 1024;
const size = kb > 1024 ? `${(kb / 1024).toFixed(2)} MB` : `${kb.toFixed(2)} KB`;
message += `📏 Unpacked Size: ${size}\n`;
}
if (dist.fileCount) {
message += `📁 File Count: ${dist.fileCount}\n`;
}
message += `\nNote: For more detailed bundle analysis, consider using bundlephobia.com`;
await cache.set(cacheKey, message, CACHE_SETTINGS.LONG_TTL);
return createSuccessResponse(message);
} catch (error) {
return createErrorResponse(error, `Failed to analyze bundle size for ${input.packageName}`);
}
}
async function handleAnalyzeDependencies(args: unknown) {
const input = AnalyzeDependenciesSchema.parse(args);
try {
const resolvedCwd = await resolveWorkingDirectory(input.cwd);
const { packageManager } = await detectPackageManager(resolvedCwd);
let message = "📊 Dependency Analysis:\n\n";
let hasIssues = false;
// Read package.json
const packageJsonPath = path.join(resolvedCwd, 'package.json');
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf-8'));
// Check for circular dependencies using npm ls
if (input.circular && packageManager === "npm") {
try {
const { stdout, stderr } = await execa('npm', ['ls', '--json'], {
cwd: resolvedCwd,
reject: false
});
if (stdout) {
const depsData = JSON.parse(stdout);
// Look for circular dependencies in the problems array
if (depsData.problems && depsData.problems.length > 0) {
const circularDeps = depsData.problems.filter((problem: string) =>
problem.includes('circular')
);
if (circularDeps.length > 0) {
hasIssues = true;
message += `🔄 Circular Dependencies Found:\n`;
circularDeps.forEach((dep: string) => {
message += ` • ${dep}\n`;
});
message += '\n';
}
}
}
} catch (circularError) {
// Continue with other checks
}
}
// Check for missing dependencies
try {
const { stderr } = await execa(packageManager, ['list'], {
cwd: resolvedCwd,
reject: false
});
if (stderr && stderr.includes('missing:')) {
hasIssues = true;
const missingDeps = stderr.match(/missing: [^\n]+/g) || [];
if (missingDeps.length > 0) {
message += `❌ Missing Dependencies:\n`;
missingDeps.forEach(dep => {
message += ` • ${dep.replace('missing: ', '')}\n`;
});
message += '\n';
}
}
} catch (missingError) {
// Continue with other checks
}
// Check for unused dependencies (basic check)
const dependencies = Object.keys(packageJson.dependencies || {});
const devDependencies = Object.keys(packageJson.devDependencies || {});
const allDeps = [...dependencies, ...devDependencies];
if (input.orphans) {
if (allDeps.length > 0) {
message += `📦 Dependency Summary:\n`;
message += ` • Production dependencies: ${dependencies.length}\n`;
message += ` • Dev dependencies: ${devDependencies.length}\n`;
message += ` • Total: ${allDeps.length}\n\n`;
message += `💡 Tips:\n`;
message += ` • Run 'npm dedupe' to optimize dependency tree\n`;
message += ` • Use 'npm prune' to remove extraneous packages\n`;
message += ` • Consider using 'npm-check' for more detailed analysis\n`;
}
}
if (!hasIssues && allDeps.length === 0) {
message += "✅ No dependency issues found!";
} else if (!hasIssues) {
message += "✅ No circular dependencies or missing packages detected!";
}
return createSuccessResponse(message);
} catch (error) {
return createErrorResponse(error, 'Failed to analyze dependencies');
}
}
async function handleDownloadStats(args: unknown) {
const input = DownloadStatsSchema.parse(args);
const cacheKey = `stats:${input.packageName}:${input.period}`;
// Check cache first
const cached = await cache.get(cacheKey);
if (cached) {
return createSuccessResponse(cached as string);
}
try {
// npm download stats API
const statsUrl = `https://api.npmjs.org/downloads/point/${input.period}/${encodeURIComponent(input.packageName)}`;
const response = await fetch(statsUrl);
if (!response.ok) {
return createErrorResponse(
new Error(`Failed to fetch download stats for ${input.packageName}`),
`Could not retrieve download statistics for ${input.packageName}`
);
}
const data = await response.json();
let message = `📊 Download Statistics for ${input.packageName}:\n\n`;
message += `📅 Period: ${input.period}\n`;
message += `📥 Downloads: ${data.downloads.toLocaleString()}\n`;
if (data.start && data.end) {
message += `📆 Date Range: ${data.start} to ${data.end}\n`;
}
// Calculate daily average
const days = {
'last-day': 1,
'last-week': 7,
'last-month': 30,
'last-year': 365
};
const avgDaily = Math.round(data.downloads / days[input.period]);
message += `📈 Daily Average: ${avgDaily.toLocaleString()}`;
await cache.set(cacheKey, message, CACHE_SETTINGS.SHORT_TTL);
return createSuccessResponse(message);
} catch (error) {
return createErrorResponse(error, `Failed to fetch download stats for ${input.packageName}`);
}
}