@nekzus/mcp-server
Version:
NPM Sentinel MCP - A powerful Model Context Protocol (MCP) server that revolutionizes NPM package analysis through AI. Built to integrate with Claude and Anthropic AI, it provides real-time intelligence on package security, dependencies, and performance.
1,194 lines (1,193 loc) • 149 kB
JavaScript
#!/usr/bin/env node
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import fetch from 'node-fetch';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import { z } from 'zod';
// Cache configuration
const CACHE_TTL_SHORT = 15 * 60 * 1000; // 15 minutes
const CACHE_TTL_MEDIUM = 60 * 60 * 1000; // 1 hour
const CACHE_TTL_LONG = 6 * 60 * 60 * 1000; // 6 hours
const CACHE_TTL_VERY_LONG = 24 * 60 * 60 * 1000; // 24 hours
const MAX_CACHE_SIZE = 500; // Max number of items in cache
const apiCache = new Map();
function generateCacheKey(toolName, ...args) {
// Simple key generation, ensure consistent order and stringification of args
return `${toolName}:${args.map((arg) => String(arg)).join(':')}`;
}
function cacheGet(key) {
const entry = apiCache.get(key);
if (entry && entry.expiresAt > Date.now()) {
return entry.data;
}
if (entry && entry.expiresAt <= Date.now()) {
apiCache.delete(key); // Remove stale entry
}
return undefined;
}
function cacheSet(key, value, ttlMilliseconds) {
if (ttlMilliseconds <= 0)
return; // Do not cache if TTL is zero or negative
const expiresAt = Date.now() + ttlMilliseconds;
apiCache.set(key, { data: value, expiresAt });
// Basic FIFO eviction strategy if cache exceeds max size
if (apiCache.size > MAX_CACHE_SIZE) {
// To make it FIFO, we need to ensure Map iteration order is insertion order (which it is)
const oldestKey = apiCache.keys().next().value;
if (oldestKey) {
apiCache.delete(oldestKey);
}
}
}
// Zod schemas for npm package data
export const NpmMaintainerSchema = z
.object({
name: z.string(),
email: z.string().optional(),
url: z.string().optional(),
})
.passthrough();
export const NpmPackageVersionSchema = z
.object({
name: z.string(),
version: z.string(),
description: z.string().optional(),
author: z
.union([
z.string(),
z
.object({
name: z.string().optional(),
email: z.string().optional(),
url: z.string().optional(),
})
.passthrough(),
])
.optional(),
license: z.string().optional(),
repository: z
.object({
type: z.string().optional(),
url: z.string().optional(),
})
.passthrough()
.optional(),
bugs: z
.object({
url: z.string().optional(),
})
.passthrough()
.optional(),
homepage: z.string().optional(),
dependencies: z.record(z.string()).optional(),
devDependencies: z.record(z.string()).optional(),
peerDependencies: z.record(z.string()).optional(),
types: z.string().optional(),
typings: z.string().optional(),
dist: z
.object({ shasum: z.string().optional(), tarball: z.string().optional() })
.passthrough()
.optional(),
})
.passthrough();
export const NpmPackageInfoSchema = z
.object({
name: z.string(),
'dist-tags': z.record(z.string()),
versions: z.record(NpmPackageVersionSchema),
time: z.record(z.string()).optional(),
repository: z
.object({
type: z.string().optional(),
url: z.string().optional(),
})
.passthrough()
.optional(),
bugs: z
.object({
url: z.string().optional(),
})
.passthrough()
.optional(),
homepage: z.string().optional(),
maintainers: z.array(NpmMaintainerSchema).optional(),
})
.passthrough();
export const NpmPackageDataSchema = z.object({
name: z.string(),
version: z.string(),
description: z.string().optional(),
license: z.string().optional(),
dependencies: z.record(z.string()).optional(),
devDependencies: z.record(z.string()).optional(),
peerDependencies: z.record(z.string()).optional(),
types: z.string().optional(),
typings: z.string().optional(),
});
export const BundlephobiaDataSchema = z.object({
size: z.number(),
gzip: z.number(),
dependencyCount: z.number(),
});
export const NpmDownloadsDataSchema = z.object({
downloads: z.number(),
start: z.string(),
end: z.string(),
package: z.string(),
});
function isValidNpmsResponse(data) {
if (typeof data !== 'object' || data === null) {
console.debug('NpmsApiResponse validation: Response is not an object or is null');
return false;
}
const response = data;
// Check score structure
if (!response.score ||
typeof response.score !== 'object' ||
!('final' in response.score) ||
typeof response.score.final !== 'number' ||
!('detail' in response.score) ||
typeof response.score.detail !== 'object') {
console.debug('NpmsApiResponse validation: Invalid score structure');
return false;
}
// Check score detail metrics
const detail = response.score.detail;
if (typeof detail.quality !== 'number' ||
typeof detail.popularity !== 'number' ||
typeof detail.maintenance !== 'number') {
console.debug('NpmsApiResponse validation: Invalid score detail metrics');
return false;
}
// Check collected data structure
if (!response.collected ||
typeof response.collected !== 'object' ||
!response.collected.metadata ||
typeof response.collected.metadata !== 'object' ||
typeof response.collected.metadata.name !== 'string' ||
typeof response.collected.metadata.version !== 'string') {
console.debug('NpmsApiResponse validation: Invalid collected data structure');
return false;
}
// Check npm data
if (!response.collected.npm ||
typeof response.collected.npm !== 'object' ||
!Array.isArray(response.collected.npm.downloads) ||
typeof response.collected.npm.starsCount !== 'number') {
console.debug('NpmsApiResponse validation: Invalid npm data structure');
return false;
}
// Optional github data check
if (response.collected.github) {
if (typeof response.collected.github !== 'object' ||
typeof response.collected.github.starsCount !== 'number' ||
typeof response.collected.github.forksCount !== 'number' ||
typeof response.collected.github.subscribersCount !== 'number' ||
!response.collected.github.issues ||
typeof response.collected.github.issues !== 'object' ||
typeof response.collected.github.issues.count !== 'number' ||
typeof response.collected.github.issues.openCount !== 'number') {
console.debug('NpmsApiResponse validation: Invalid github data structure');
return false;
}
}
return true;
}
export const NpmSearchResultSchema = z
.object({
objects: z.array(z.object({
package: z.object({
name: z.string(),
version: z.string(),
description: z.string().optional(),
keywords: z.array(z.string()).optional(),
publisher: z
.object({
username: z.string(),
email: z.string().optional(),
})
.optional(),
links: z
.object({
npm: z.string().optional(),
homepage: z.string().optional(),
repository: z.string().optional(),
bugs: z.string().optional(),
})
.optional(),
date: z.string().optional(),
}),
score: z.object({
final: z.number(),
detail: z.object({
quality: z.number(),
popularity: z.number(),
maintenance: z.number(),
}),
}),
searchScore: z.number(),
})),
total: z.number(), // total is a sibling of objects
})
.passthrough();
// Logger function that uses stderr - only for critical errors
const log = (...args) => {
// Filter out server status messages
const message = args[0];
if (typeof message === 'string' &&
(!message.startsWith('[Server]') || message.includes('error') || message.includes('Error'))) {
console.error(...args);
}
};
// Type guards for API responses
function isNpmPackageInfo(data) {
return (typeof data === 'object' &&
data !== null &&
(!('maintainers' in data) ||
(Array.isArray(data.maintainers) &&
(data.maintainers?.every((m) => typeof m === 'object' &&
m !== null &&
'name' in m &&
'email' in m &&
typeof m.name === 'string' &&
typeof m.email === 'string') ??
true))));
}
function isNpmPackageData(data) {
try {
return NpmPackageDataSchema.parse(data) !== null;
}
catch {
return false;
}
}
function isBundlephobiaData(data) {
try {
return BundlephobiaDataSchema.parse(data) !== null;
}
catch {
return false;
}
}
function isNpmDownloadsData(data) {
try {
return NpmDownloadsDataSchema.parse(data) !== null;
}
catch {
return false;
}
}
export async function handleNpmVersions(args) {
try {
const packagesToProcess = args.packages || [];
if (packagesToProcess.length === 0) {
throw new Error('No package names provided');
}
const processedResults = await Promise.all(packagesToProcess.map(async (pkgInput) => {
let name = '';
if (typeof pkgInput === 'string') {
const atIdx = pkgInput.lastIndexOf('@');
if (atIdx > 0) {
name = pkgInput.slice(0, atIdx);
}
else {
name = pkgInput;
}
}
else {
return {
packageInput: JSON.stringify(pkgInput),
packageName: 'unknown_package_input',
status: 'error',
error: 'Invalid package input type',
data: null,
message: 'Package input was not a string.',
};
}
if (!name) {
return {
packageInput: pkgInput,
packageName: 'empty_package_name',
status: 'error',
error: 'Empty package name derived from input',
data: null,
message: 'Package name could not be determined from input.',
};
}
const cacheKey = generateCacheKey('handleNpmVersions', name);
const cachedData = cacheGet(cacheKey); // Using any for the diverse structure from this endpoint
if (cachedData) {
return {
packageInput: pkgInput,
packageName: name,
status: 'success_cache',
error: null,
data: cachedData,
message: `Successfully fetched versions for ${name} from cache.`,
};
}
try {
const response = await fetch(`https://registry.npmjs.org/${name}`, {
headers: {
Accept: 'application/json',
'User-Agent': 'NPM-Sentinel-MCP',
},
});
if (!response.ok) {
return {
packageInput: pkgInput,
packageName: name,
status: 'error',
error: `Failed to fetch package info: ${response.status} ${response.statusText}`,
data: null,
message: `Could not retrieve information for package ${name}.`,
};
}
const data = await response.json();
if (!isNpmPackageInfo(data)) {
return {
packageInput: pkgInput,
packageName: name,
status: 'error',
error: 'Invalid package info format received from registry',
data: null,
message: `Received malformed data for package ${name}.`,
};
}
const allVersions = Object.keys(data.versions || {});
const tags = data['dist-tags'] || {};
const latestVersionTag = tags.latest || null;
const resultData = {
allVersions,
tags,
latestVersionTag,
};
cacheSet(cacheKey, resultData, CACHE_TTL_MEDIUM);
return {
packageInput: pkgInput,
packageName: name,
status: 'success',
error: null,
data: resultData,
message: `Successfully fetched versions for ${name}.`,
};
}
catch (error) {
return {
packageInput: pkgInput,
packageName: name,
status: 'error',
error: error instanceof Error ? error.message : 'Unknown processing error',
data: null,
message: `An unexpected error occurred while processing ${name}.`,
};
}
}));
const responseJson = JSON.stringify({ results: processedResults }, null, 2);
return { content: [{ type: 'text', text: responseJson }], isError: false };
}
catch (error) {
const errorResponse = JSON.stringify({
results: [],
error: `General error fetching versions: ${error instanceof Error ? error.message : 'Unknown error'}`,
}, null, 2);
return {
content: [{ type: 'text', text: errorResponse }],
isError: true,
};
}
}
export async function handleNpmLatest(args) {
try {
const packagesToProcess = args.packages || [];
if (packagesToProcess.length === 0) {
throw new Error('No package names provided');
}
const processedResults = await Promise.all(packagesToProcess.map(async (pkgInput) => {
let name = '';
let versionTag = 'latest'; // Default to 'latest'
if (typeof pkgInput === 'string') {
const atIdx = pkgInput.lastIndexOf('@');
if (atIdx > 0) {
name = pkgInput.slice(0, atIdx);
versionTag = pkgInput.slice(atIdx + 1);
}
else {
name = pkgInput;
}
}
else {
return {
packageInput: JSON.stringify(pkgInput),
packageName: 'unknown_package_input',
versionQueried: versionTag,
status: 'error',
error: 'Invalid package input type',
data: null,
message: 'Package input was not a string.',
};
}
if (!name) {
return {
packageInput: pkgInput,
packageName: 'empty_package_name',
versionQueried: versionTag,
status: 'error',
error: 'Empty package name derived from input',
data: null,
message: 'Package name could not be determined from input.',
};
}
const cacheKey = generateCacheKey('handleNpmLatest', name, versionTag);
const cachedData = cacheGet(cacheKey); // Using any for the diverse structure from this endpoint
if (cachedData) {
return {
packageInput: pkgInput,
packageName: name,
versionQueried: versionTag,
status: 'success_cache',
error: null,
data: cachedData,
message: `Successfully fetched details for ${name}@${versionTag} from cache.`,
};
}
try {
const response = await fetch(`https://registry.npmjs.org/${name}/${versionTag}`, {
headers: {
Accept: 'application/json',
'User-Agent': 'NPM-Sentinel-MCP',
},
});
if (!response.ok) {
let errorMsg = `Failed to fetch package version: ${response.status} ${response.statusText}`;
if (response.status === 404) {
errorMsg = `Package ${name}@${versionTag} not found.`;
}
return {
packageInput: pkgInput,
packageName: name,
versionQueried: versionTag,
status: 'error',
error: errorMsg,
data: null,
message: `Could not retrieve version ${versionTag} for package ${name}.`,
};
}
const data = await response.json();
if (!isNpmPackageVersionData(data)) {
return {
packageInput: pkgInput,
packageName: name,
versionQueried: versionTag,
status: 'error',
error: 'Invalid package data format received for version',
data: null,
message: `Received malformed data for ${name}@${versionTag}.`,
};
}
const versionData = {
name: data.name,
version: data.version,
description: data.description || null,
author: (typeof data.author === 'string' ? data.author : data.author?.name) || null,
license: data.license || null,
homepage: data.homepage || null,
repositoryUrl: data.repository?.url || null,
bugsUrl: data.bugs?.url || null,
dependenciesCount: Object.keys(data.dependencies || {}).length,
devDependenciesCount: Object.keys(data.devDependencies || {}).length,
peerDependenciesCount: Object.keys(data.peerDependencies || {}).length,
dist: data.dist || null,
types: data.types || data.typings || null,
};
cacheSet(cacheKey, versionData, CACHE_TTL_MEDIUM);
return {
packageInput: pkgInput,
packageName: name,
versionQueried: versionTag,
status: 'success',
error: null,
data: versionData,
message: `Successfully fetched details for ${data.name}@${data.version}.`,
};
}
catch (error) {
return {
packageInput: pkgInput,
packageName: name,
versionQueried: versionTag,
status: 'error',
error: error instanceof Error ? error.message : 'Unknown processing error',
data: null,
message: `An unexpected error occurred while processing ${pkgInput}.`,
};
}
}));
const responseJson = JSON.stringify({ results: processedResults }, null, 2);
return { content: [{ type: 'text', text: responseJson }], isError: false };
}
catch (error) {
const errorResponse = JSON.stringify({
results: [],
error: `General error fetching latest package information: ${error instanceof Error ? error.message : 'Unknown error'}`,
}, null, 2);
return {
content: [{ type: 'text', text: errorResponse }],
isError: true,
};
}
}
export async function handleNpmDeps(args) {
try {
const packagesToProcess = args.packages || [];
if (packagesToProcess.length === 0) {
throw new Error('No package names provided');
}
const processedResults = await Promise.all(packagesToProcess.map(async (pkgInput) => {
let name = '';
let version = 'latest'; // Default to 'latest'
if (typeof pkgInput === 'string') {
const atIdx = pkgInput.lastIndexOf('@');
if (atIdx > 0) {
name = pkgInput.slice(0, atIdx);
version = pkgInput.slice(atIdx + 1);
}
else {
name = pkgInput;
}
}
else {
return {
package: 'unknown_package_input',
status: 'error',
error: 'Invalid package input type',
data: null,
message: 'Package input was not a string.',
};
}
const packageNameForOutput = version === 'latest' ? name : `${name}@${version}`;
// Note: The cache key should ideally use the *resolved* version if 'latest' is input.
// However, to get the resolved version, we need an API call. For simplicity in this step,
// we'll cache based on the input version string. This means 'latest' will be cached as 'latest'.
// A more advanced caching would fetch resolved version first if 'latest' is given.
const cacheKey = generateCacheKey('handleNpmDeps', name, version);
const cachedData = cacheGet(cacheKey);
if (cachedData) {
return {
package: cachedData.packageNameForCache || packageNameForOutput, // Use cached name if available
status: 'success_cache',
error: null,
data: cachedData.depData,
message: `Dependencies for ${cachedData.packageNameForCache || packageNameForOutput} from cache.`,
};
}
try {
const response = await fetch(`https://registry.npmjs.org/${name}/${version}`, {
headers: {
Accept: 'application/json',
'User-Agent': 'NPM-Sentinel-MCP',
},
});
if (!response.ok) {
return {
package: packageNameForOutput,
status: 'error',
error: `Failed to fetch package info: ${response.status} ${response.statusText}`,
data: null,
message: `Could not retrieve information for ${packageNameForOutput}.`,
};
}
const rawData = await response.json();
if (!isNpmPackageData(rawData)) {
return {
package: packageNameForOutput,
status: 'error',
error: 'Invalid package data received from registry',
data: null,
message: `Received malformed data for ${packageNameForOutput}.`,
};
}
const mapDeps = (deps) => {
if (!deps)
return [];
return Object.entries(deps).map(([depName, depVersion]) => ({
name: depName,
version: depVersion,
}));
};
const depData = {
dependencies: mapDeps(rawData.dependencies),
devDependencies: mapDeps(rawData.devDependencies),
peerDependencies: mapDeps(rawData.peerDependencies),
};
const actualVersion = rawData.version || version; // Use version from response if available
const finalPackageName = `${name}@${actualVersion}`;
// Store with the actual resolved package name if 'latest' was used
cacheSet(cacheKey, { depData, packageNameForCache: finalPackageName }, CACHE_TTL_MEDIUM);
return {
package: finalPackageName,
status: 'success',
error: null,
data: depData,
message: `Dependencies for ${finalPackageName}`,
};
}
catch (error) {
return {
package: packageNameForOutput,
status: 'error',
error: error instanceof Error ? error.message : 'Unknown processing error',
data: null,
message: `An unexpected error occurred while processing ${packageNameForOutput}.`,
};
}
}));
const responseJson = JSON.stringify({ results: processedResults }, null, 2);
return { content: [{ type: 'text', text: responseJson }], isError: false };
}
catch (error) {
const errorResponse = JSON.stringify({
results: [],
error: `General error fetching dependencies: ${error instanceof Error ? error.message : 'Unknown error'}`,
}, null, 2);
return {
content: [{ type: 'text', text: errorResponse }],
isError: true,
};
}
}
export async function handleNpmTypes(args) {
try {
const packagesToProcess = args.packages || [];
if (packagesToProcess.length === 0) {
throw new Error('No package names provided');
}
const processedResults = await Promise.all(packagesToProcess.map(async (pkgInput) => {
let name = '';
let version = 'latest'; // Default to 'latest'
if (typeof pkgInput === 'string') {
const atIdx = pkgInput.lastIndexOf('@');
if (atIdx > 0) {
name = pkgInput.slice(0, atIdx);
version = pkgInput.slice(atIdx + 1);
}
else {
name = pkgInput;
}
}
else {
return {
package: 'unknown_package_input',
status: 'error',
error: 'Invalid package input type',
data: null,
message: 'Package input was not a string.',
};
}
const packageNameForOutput = version === 'latest' ? name : `${name}@${version}`;
// As with handleNpmDeps, we cache based on the input version string for simplicity.
const cacheKey = generateCacheKey('handleNpmTypes', name, version);
const cachedData = cacheGet(cacheKey);
if (cachedData) {
return {
package: cachedData.finalPackageName || packageNameForOutput,
status: 'success_cache',
error: null,
data: cachedData.typesData,
message: `TypeScript information for ${cachedData.finalPackageName || packageNameForOutput} from cache.`,
};
}
try {
const response = await fetch(`https://registry.npmjs.org/${name}/${version}`, {
headers: {
Accept: 'application/json',
'User-Agent': 'NPM-Sentinel-MCP',
},
});
if (!response.ok) {
return {
package: packageNameForOutput,
status: 'error',
error: `Failed to fetch package info: ${response.status} ${response.statusText}`,
data: null,
message: `Could not retrieve information for ${packageNameForOutput}.`,
};
}
const mainPackageData = (await response.json());
const actualVersion = mainPackageData.version || version; // Use version from response
const finalPackageName = `${name}@${actualVersion}`;
const hasBuiltInTypes = Boolean(mainPackageData.types || mainPackageData.typings);
const typesPath = mainPackageData.types || mainPackageData.typings || null;
const typesPackageName = `@types/${name.replace('@', '').replace('/', '__')}`;
let typesPackageInfo = {
name: typesPackageName,
version: null,
isAvailable: false,
};
try {
const typesResponse = await fetch(`https://registry.npmjs.org/${typesPackageName}/latest`, {
headers: {
Accept: 'application/json',
'User-Agent': 'NPM-Sentinel-MCP',
},
});
if (typesResponse.ok) {
const typesData = (await typesResponse.json());
typesPackageInfo = {
name: typesPackageName,
version: typesData.version || 'unknown',
isAvailable: true,
};
}
}
catch (typesError) {
// Keep this debug for visibility on @types fetch failures
console.debug(`Could not fetch @types package ${typesPackageName}: ${typesError}`);
}
const resultData = {
mainPackage: {
name: name,
version: actualVersion,
hasBuiltInTypes: hasBuiltInTypes,
typesPath: typesPath,
},
typesPackage: typesPackageInfo,
};
cacheSet(cacheKey, { typesData: resultData, finalPackageName }, CACHE_TTL_LONG);
return {
package: finalPackageName,
status: 'success',
error: null,
data: resultData,
message: `TypeScript information for ${finalPackageName}`,
};
}
catch (error) {
return {
package: packageNameForOutput,
status: 'error',
error: error instanceof Error ? error.message : 'Unknown processing error',
data: null,
message: `An unexpected error occurred while processing ${packageNameForOutput}.`,
};
}
}));
const responseJson = JSON.stringify({ results: processedResults }, null, 2);
return { content: [{ type: 'text', text: responseJson }], isError: false };
}
catch (error) {
const errorResponse = JSON.stringify({
results: [],
error: `General error checking TypeScript types: ${error instanceof Error ? error.message : 'Unknown error'}`,
}, null, 2);
return {
content: [{ type: 'text', text: errorResponse }],
isError: true,
};
}
}
export async function handleNpmSize(args) {
try {
const packagesToProcess = args.packages || [];
if (packagesToProcess.length === 0) {
throw new Error('No package names provided');
}
const processedResults = await Promise.all(packagesToProcess.map(async (pkgInput) => {
let name = '';
let version = 'latest'; // Default to 'latest'
if (typeof pkgInput === 'string') {
const atIdx = pkgInput.lastIndexOf('@');
if (atIdx > 0) {
name = pkgInput.slice(0, atIdx);
version = pkgInput.slice(atIdx + 1);
}
else {
name = pkgInput;
}
}
else {
return {
package: 'unknown_package_input',
status: 'error',
error: 'Invalid package input type',
data: null,
message: 'Package input was not a string.',
};
}
const bundlephobiaQuery = version === 'latest' ? name : `${name}@${version}`;
const packageNameForOutput = bundlephobiaQuery;
const cacheKey = generateCacheKey('handleNpmSize', bundlephobiaQuery);
const cachedData = cacheGet(cacheKey);
if (cachedData) {
return {
package: packageNameForOutput, // Or cachedData.packageName if stored
status: 'success_cache',
error: null,
data: cachedData,
message: `Size information for ${packageNameForOutput} from cache.`,
};
}
try {
const response = await fetch(`https://bundlephobia.com/api/size?package=${bundlephobiaQuery}`, {
headers: {
Accept: 'application/json',
'User-Agent': 'NPM-Sentinel-MCP',
},
});
if (!response.ok) {
let errorMsg = `Failed to fetch package size: ${response.status} ${response.statusText}`;
if (response.status === 404) {
errorMsg = `Package ${packageNameForOutput} not found or version not available on Bundlephobia.`;
}
return {
package: packageNameForOutput,
status: 'error',
error: errorMsg,
data: null,
message: `Could not retrieve size information for ${packageNameForOutput}.`,
};
}
const rawData = await response.json();
if (rawData.error) {
return {
package: packageNameForOutput,
status: 'error',
error: `Bundlephobia error: ${rawData.error.message || 'Unknown error'}`,
data: null,
message: `Bundlephobia reported an error for ${packageNameForOutput}.`,
};
}
if (!isBundlephobiaData(rawData)) {
return {
package: packageNameForOutput,
status: 'error',
error: 'Invalid package data received from Bundlephobia',
data: null,
message: `Received malformed size data for ${packageNameForOutput}.`,
};
}
const typedRawData = rawData;
const sizeData = {
name: typedRawData.name || name,
version: typedRawData.version || (version === 'latest' ? 'latest_resolved' : version),
sizeInKb: Number((typedRawData.size / 1024).toFixed(2)),
gzipInKb: Number((typedRawData.gzip / 1024).toFixed(2)),
dependencyCount: typedRawData.dependencyCount,
};
cacheSet(cacheKey, sizeData, CACHE_TTL_MEDIUM);
return {
package: packageNameForOutput,
status: 'success',
error: null,
data: sizeData,
message: `Size information for ${packageNameForOutput}`,
};
}
catch (error) {
return {
package: packageNameForOutput,
status: 'error',
error: error instanceof Error ? error.message : 'Unknown processing error',
data: null,
message: `An unexpected error occurred while processing ${packageNameForOutput}.`,
};
}
}));
const responseJson = JSON.stringify({ results: processedResults }, null, 2);
return { content: [{ type: 'text', text: responseJson }], isError: false };
}
catch (error) {
const errorResponse = JSON.stringify({
results: [],
error: `General error fetching package sizes: ${error instanceof Error ? error.message : 'Unknown error'}`,
}, null, 2);
return {
content: [{ type: 'text', text: errorResponse }],
isError: true,
};
}
}
export async function handleNpmVulnerabilities(args) {
try {
const packagesToProcess = args.packages || [];
if (packagesToProcess.length === 0) {
throw new Error('No package names provided');
}
const processedResults = await Promise.all(packagesToProcess.map(async (pkgInput) => {
let name = '';
let version = undefined;
if (typeof pkgInput === 'string') {
const atIdx = pkgInput.lastIndexOf('@');
if (atIdx > 0) {
name = pkgInput.slice(0, atIdx);
version = pkgInput.slice(atIdx + 1);
}
else {
name = pkgInput;
}
}
else if (typeof pkgInput === 'object' && pkgInput !== null) {
name = pkgInput.name;
version = pkgInput.version;
}
const packageNameForOutput = version ? `${name}@${version}` : name;
const cacheKey = generateCacheKey('handleNpmVulnerabilities', name, version || 'all');
const cachedData = cacheGet(cacheKey);
if (cachedData) {
return {
package: packageNameForOutput,
versionQueried: version || null,
status: 'success_cache',
vulnerabilities: cachedData.vulnerabilities,
message: `${cachedData.message} (from cache)`,
};
}
const osvBody = {
package: {
name,
ecosystem: 'npm',
},
};
if (version) {
osvBody.version = version;
}
const response = await fetch('https://api.osv.dev/v1/query', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(osvBody),
});
const queryVersionSpecified = !!version;
if (!response.ok) {
const errorResult = {
package: packageNameForOutput,
versionQueried: version || null,
status: 'error',
error: `OSV API Error: ${response.statusText}`,
vulnerabilities: [],
};
// Do not cache error responses from OSV API as they might be temporary
return errorResult;
}
const data = (await response.json());
const vulns = data.vulns || [];
let message;
if (vulns.length === 0) {
message = `No known vulnerabilities found${queryVersionSpecified ? ' for the specified version' : ''}.`;
}
else {
message = `${vulns.length} vulnerability(ies) found${queryVersionSpecified ? ' for the specified version' : ''}.`;
}
const processedVulns = vulns.map((vuln) => {
const sev = typeof vuln.severity === 'object'
? vuln.severity.type || 'Unknown'
: vuln.severity || 'Unknown';
const refs = vuln.references ? vuln.references.map((r) => r.url) : [];
const affectedRanges = [];
const affectedVersionsListed = [];
const vulnerabilityDetails = {
summary: vuln.summary,
severity: sev,
references: refs,
};
if (vuln.affected && vuln.affected.length > 0) {
const lifecycle = {};
const firstAffectedEvents = vuln.affected[0]?.ranges?.[0]?.events;
if (firstAffectedEvents) {
const introducedEvent = firstAffectedEvents.find((e) => e.introduced);
const fixedEvent = firstAffectedEvents.find((e) => e.fixed);
if (introducedEvent?.introduced)
lifecycle.introduced = introducedEvent.introduced;
if (fixedEvent?.fixed)
lifecycle.fixed = fixedEvent.fixed;
}
if (Object.keys(lifecycle).length > 0) {
vulnerabilityDetails.lifecycle = lifecycle;
if (queryVersionSpecified && version && lifecycle.fixed) {
const queriedParts = version.split('.').map(Number);
const fixedParts = lifecycle.fixed.split('.').map(Number);
let isFixedDecision = false;
const maxLength = Math.max(queriedParts.length, fixedParts.length);
for (let i = 0; i < maxLength; i++) {
const qp = queriedParts[i] || 0;
const fp = fixedParts[i] || 0;
if (fp < qp) {
isFixedDecision = true;
break;
}
if (fp > qp) {
isFixedDecision = false;
break;
}
if (i === maxLength - 1) {
isFixedDecision = fixedParts.length <= queriedParts.length;
}
}
vulnerabilityDetails.isFixedInQueriedVersion = isFixedDecision;
}
}
}
if (!queryVersionSpecified && vuln.affected) {
for (const aff of vuln.affected) {
if (aff.ranges) {
for (const range of aff.ranges) {
affectedRanges.push({ type: range.type, events: range.events });
}
}
if (aff.versions && aff.versions.length > 0) {
affectedVersionsListed.push(...aff.versions);
}
}
if (affectedRanges.length > 0) {
vulnerabilityDetails.affectedRanges = affectedRanges;
}
if (affectedVersionsListed.length > 0) {
vulnerabilityDetails.affectedVersionsListed = affectedVersionsListed;
}
}
return vulnerabilityDetails;
});
const resultToCache = {
vulnerabilities: processedVulns,
message: message,
};
cacheSet(cacheKey, resultToCache, CACHE_TTL_MEDIUM);
return {
package: packageNameForOutput,
versionQueried: version || null,
status: 'success',
vulnerabilities: processedVulns,
message: message,
};
}));
const responseJson = JSON.stringify({ results: processedResults }, null, 2);
return { content: [{ type: 'text', text: responseJson }], isError: false };
}
catch (error) {
const errorResponse = JSON.stringify({
results: [],
error: `General error checking vulnerabilities: ${error instanceof Error ? error.message : 'Unknown error'}`,
}, null, 2);
return {
content: [{ type: 'text', text: errorResponse }],
isError: true,
};
}
}
export async function handleNpmTrends(args) {
try {
const packagesToProcess = args.packages || [];
if (packagesToProcess.length === 0) {
throw new Error('No package names provided for trends analysis.');
}
const period = args.period && ['last-week', 'last-month', 'last-year'].includes(args.period)
? args.period
: 'last-month';
const periodDaysMap = {
'last-week': 7,
'last-month': 30,
'last-year': 365,
};
const daysInPeriod = periodDaysMap[period];
const processedResults = await Promise.all(packagesToProcess.map(async (pkgInput) => {
let name = '';
if (typeof pkgInput === 'string') {
const atIdx = pkgInput.lastIndexOf('@');
name = atIdx > 0 ? pkgInput.slice(0, atIdx) : pkgInput;
}
else {
return {
packageInput: JSON.stringify(pkgInput),
packageName: 'unknown_package_input',
status: 'error',
error: 'Invalid package input type',
data: null,
};
}
if (!name) {
return {
packageInput: pkgInput,
packageName: 'empty_package_name',
status: 'error',
error: 'Empty package name derived from input',
data: null,
};
}
const cacheKey = generateCacheKey('handleNpmTrends', name, period);
const cachedData = cacheGet(cacheKey);
if (cachedData) {
return {
packageInput: pkgInput,
packageName: name,
status: 'success_cache',
error: null,
data: cachedData,
message: `Download trends for ${name} (${period}) from cache.`,
};
}
try {
const response = await fetch(`https://api.npmjs.org/downloads/point/${period}/${name}`, {
headers: {
Accept: 'application/json',
'User-Agent': 'NPM-Sentinel-MCP',
},
});
if (!response.ok) {
let errorMsg = `Failed to fetch download trends: ${response.status} ${response.statusText}`;
if (response.status === 404) {
errorMsg = `Package ${name} not found or no download data for the period.`;
}
return {
packageInput: pkgInput,
packageName: name,
status: 'error',
error: errorMsg,
data: null,
};
}
const data = await response.json();
if (!isNpmDownloadsData(data)) {
return {
packageInput: pkgInput,
packageName: name,
status: 'error',
error: 'Invalid response format from npm downloads API',
data: null,
};
}
const trendData = {
downloads: data.downloads,
period: period,
startDate: data.start,
endDate: data.end,
averageDailyDownloads: Math.round(data.downloads / daysInPeriod),
};
cacheSet(cacheKey, trendData, CACHE_TTL_MEDIUM);
return {
packageInput: pkgInput,
packageName: name,
status: 'success',
error: null,
data: trendData,
message: `Successfully fetched download trends for ${name} (${period}).`,
};
}
catch (error) {
return {
packageInput: pkgInput,
packageName: name,
status: 'error',
error: error instanceof Error ? error.message : 'Unknown processing error',
data: null,
};
}
}));
let totalSuccessful = 0;
let overallTotalDownloads = 0;
for (const result of processedResults) {
if (result.status === 'success' && result.data) {
totalSuccessful++;
overallTotalDownloads += result.data.downloads;