@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,206 lines (1,205 loc) • 172 kB
JavaScript
#!/usr/bin/env node
import * as crypto from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import fetch from 'node-fetch';
import { z } from 'zod';
// Cache configuration
let NPM_REGISTRY_URL = (process.env.NPM_REGISTRY_URL || 'https://registry.npmjs.org').replace(/\/$/, '');
// 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();
let currentLockfileHash = null;
function getLockfileHash() {
const lockfiles = ['pnpm-lock.yaml', 'package-lock.json', 'yarn.lock'];
for (const lockfile of lockfiles) {
const fullPath = path.join(process.cwd(), lockfile);
if (fs.existsSync(fullPath)) {
try {
const content = fs.readFileSync(fullPath);
return crypto.createHash('md5').update(content).digest('hex');
}
catch (e) {
console.error(`Error reading lockfile ${lockfile}:`, e);
}
}
}
return null;
}
// Initialize hash
currentLockfileHash = getLockfileHash();
function checkCacheInvalidation() {
const newHash = getLockfileHash();
if (newHash !== currentLockfileHash) {
console.error('[Cache] Lockfile changed, invalidating all cache entries.');
apiCache.clear();
currentLockfileHash = newHash;
}
}
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) {
// Check for global invalidation first
checkCacheInvalidation();
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(),
})
.loose();
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(),
})
.loose(),
])
.optional(),
license: z.string().optional(),
repository: z
.object({
type: z.string().optional(),
url: z.string().optional(),
})
.loose()
.optional(),
bugs: z
.object({
url: z.string().optional(),
})
.loose()
.optional(),
homepage: z.string().optional(),
dependencies: z.record(z.string(), z.string()).optional(),
devDependencies: z.record(z.string(), z.string()).optional(),
peerDependencies: z.record(z.string(), z.string()).optional(),
types: z.string().optional(),
typings: z.string().optional(),
dist: z
.object({ shasum: z.string().optional(), tarball: z.string().optional() })
.loose()
.optional(),
})
.loose();
export const NpmPackageInfoSchema = z
.object({
name: z.string(),
'dist-tags': z.record(z.string(), z.string()),
versions: z.record(z.string(), NpmPackageVersionSchema),
time: z.record(z.string(), z.string()).optional(),
repository: z
.object({
type: z.string().optional(),
url: z.string().optional(),
})
.loose()
.optional(),
bugs: z
.object({
url: z.string().optional(),
})
.loose()
.optional(),
homepage: z.string().optional(),
maintainers: z.array(NpmMaintainerSchema).optional(),
})
.loose();
export const NpmPackageDataSchema = z
.object({
name: z.string(),
version: z.string(),
description: z.string().optional(),
license: z.string().optional(),
dependencies: z.record(z.string(), z.string()).optional(),
devDependencies: z.record(z.string(), z.string()).optional(),
peerDependencies: z.record(z.string(), z.string()).optional(),
types: z.string().optional(),
typings: z.string().optional(),
})
.loose();
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
})
.loose();
// 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 {
// Use safeParse to get error details
const result = NpmPackageDataSchema.safeParse(data);
if (!result.success) {
console.error('isNpmPackageData validation failed:', JSON.stringify(result.error.issues, null, 2));
}
return result.success;
}
catch (e) {
console.error('isNpmPackageData threw exception:', e);
return false;
}
}
function isBundlephobiaData(data) {
try {
return BundlephobiaDataSchema.parse(data) !== null;
}
catch {
return false;
}
}
function isNpmDownloadsData(data) {
try {
const result = NpmDownloadsDataSchema.safeParse(data);
if (!result.success) {
console.error('isNpmDownloadsData validation failed:', JSON.stringify(result.error.issues, null, 2));
}
return result.success;
}
catch {
return false;
}
}
// Helper for validating NPM package names
function isValidNpmPackageName(name) {
const npmPackageRegex = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
return (npmPackageRegex.test(name) &&
name.length <= 214 &&
!name.startsWith('_') &&
!name.startsWith('.'));
}
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 (!isValidNpmPackageName(name)) {
return {
packageInput: pkgInput,
packageName: name,
status: 'error',
error: 'Invalid package name format',
data: null,
message: `The package name "${name}" is invalid/malformed.`,
};
}
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 = args.ignoreCache ? undefined : 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(`${NPM_REGISTRY_URL}/${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 (!isValidNpmPackageName(name)) {
return {
packageInput: pkgInput,
packageName: name,
versionQueried: versionTag,
status: 'error',
error: 'Invalid package name format',
data: null,
message: `The package name "${name}" is invalid/malformed.`,
};
}
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 = args.ignoreCache ? undefined : 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(`${NPM_REGISTRY_URL}/${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.',
};
}
if (!isValidNpmPackageName(name)) {
return {
package: pkgInput,
status: 'error',
error: 'Invalid package name format',
data: null,
message: `The package name "${name}" is invalid/malformed.`,
};
}
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 = args.ignoreCache ? undefined : 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(`${NPM_REGISTRY_URL}/${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 actualVersion = rawData.version || version; // Use version from response if available
const finalPackageName = `${name}@${actualVersion}`;
// Fetch transitive dependencies from deps.dev to provide deep topological insights
const transitiveGraphRaw = await fetchTransitiveDependenciesFromDepsDev(name, actualVersion);
// Erase root package from the graph to avoid self-counting if returned
const transitiveGraph = transitiveGraphRaw.filter((dep) => dep.name !== name);
const depData = {
dependencies: mapDeps(rawData.dependencies),
devDependencies: mapDeps(rawData.devDependencies),
peerDependencies: mapDeps(rawData.peerDependencies),
transitiveCount: transitiveGraph.length,
transitiveGraph: transitiveGraph,
};
// 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} (Direct: ${depData.dependencies.length}, Transitive: ${depData.transitiveCount})`,
};
}
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.',
};
}
if (!isValidNpmPackageName(name)) {
return {
package: pkgInput,
status: 'error',
error: 'Invalid package name format',
data: null,
message: `The package name "${name}" is invalid/malformed.`,
};
}
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 = args.ignoreCache ? undefined : 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(`${NPM_REGISTRY_URL}/${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(`${NPM_REGISTRY_URL}/${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.',
};
}
if (!isValidNpmPackageName(name)) {
return {
package: pkgInput,
status: 'error',
error: 'Invalid package name format',
data: null,
message: `The package name "${name}" is invalid/malformed.`,
};
}
const bundlephobiaQuery = version === 'latest' ? name : `${name}@${version}`;
const packageNameForOutput = bundlephobiaQuery;
const cacheKey = generateCacheKey('handleNpmSize', bundlephobiaQuery);
const cachedData = args.ignoreCache ? undefined : 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,
};
}
}
// Helper to fetch full transitive dependency graph from deps.dev
async function fetchTransitiveDependenciesFromDepsDev(pkgName, version) {
try {
const encodedName = encodeURIComponent(pkgName);
const encodedVersion = encodeURIComponent(version);
const url = `https://api.deps.dev/v3/systems/npm/packages/${encodedName}/versions/${encodedVersion}:dependencies`;
const response = await fetch(url, {
headers: { Accept: 'application/json', 'User-Agent': 'NPM-Sentinel-MCP' },
});
if (!response.ok) {
console.warn(`deps.dev API returned ${response.status} for ${pkgName}@${version}`);
return [];
}
const data = (await response.json());
if (!data.nodes || !Array.isArray(data.nodes))
return [];
return data.nodes
.filter((node) => node.versionKey?.name)
.map((node) => ({
name: node.versionKey.name,
version: node.versionKey.version,
}));
}
catch (error) {
console.error(`Error fetching transitive dependencies from deps.dev for ${pkgName}:`, error);
return [];
}
}
// Helper to resolve 'latest' tag to actual version number
async function resolveLatestVersion(packageName) {
try {
const response = await fetch(`${NPM_REGISTRY_URL}/${packageName}/latest`, {
headers: { 'User-Agent': 'NPM-Sentinel-MCP' },
});
if (!response.ok)
return null;
const data = (await response.json());
return data.version || null;
}
catch {
return null;
}
}
// Known ecosystem groups that share versioning
const ECOSYSTEM_MAP = {
react: ['react-dom', 'react-server-dom-webpack', 'react-server-dom-parcel'],
};
// Helper to fetch full vulnerability details (enrichment)
async function enrichVulnerabilityData(vulnId, ignoreCache = false) {
const cacheKey = generateCacheKey('enrichVuln', vulnId);
const cached = ignoreCache ? undefined : cacheGet(cacheKey);
if (cached)
return cached;
try {
const response = await fetch(`https://api.osv.dev/v1/vulns/${vulnId}`, {
headers: { 'User-Agent': 'NPM-Sentinel-MCP' },
});
if (!response.ok)
return null;
const data = await response.json();
cacheSet(cacheKey, data, CACHE_TTL_LONG);
return data;
}
catch (error) {
console.error(`Failed to enrich vulnerability ${vulnId}:`, error);
return null;
}
}
export async function handleNpmVulnerabilities(args) {
try {
const packagesToProcess = args.packages || [];
if (packagesToProcess.length === 0) {
throw new Error('No package names provided');
}
// Prepare batch query, checking cache first
const finalBatchQueries = [];
const packageMap = new Map();
const cachedResultsMap = new Map();
const addToQuery = (name, releaseVersion, isDep) => {
const version = releaseVersion === 'latest' ? undefined : releaseVersion;
const key = `${name}@${version || 'latest'}`;
if (packageMap.has(key))
return; // Already requested/processed
// Check Cache
const cacheKey = generateCacheKey('handleNpmVulnerabilities', name, version || 'all');
const cachedData = args.ignoreCache ? undefined : cacheGet(cacheKey);
if (cachedData) {
// Store cached result directly using the same structure as we will build later
cachedResultsMap.set(key, {
package: `${name}${version ? `@${version}` : ''}`,
isDependency: isDep,
vulnerabilities: cachedData.vulnerabilities,
count: cachedData.vulnerabilities.length,
status: cachedData.vulnerabilities.length > 0 ? 'vulnerable' : 'secure',
source: 'cache',
});
packageMap.set(key, { name, version, isDependency: isDep });
}
else {
// Not in cache, add to API query
packageMap.set(key, { name, version, isDependency: isDep });
finalBatchQueries.push({
package: { name, ecosystem: 'npm' },
version: version === 'latest' ? undefined : version,
});
}
};
const processPackage = async (name, version) => {
const safeVersion = version || 'latest';
// Always add the root package (depth 0 logic)
addToQuery(name, safeVersion, false);
// Try to get all transitive dependencies in a single call to deps.dev
// They require a concrete version (or we pass exactly what we have)
const allDeps = await fetchTransitiveDependenciesFromDepsDev(name, safeVersion);
for (const dep of allDeps) {
// Avoid adding the root package itself again, which is included in the graph
if (dep.name === name)
continue;
addToQuery(dep.name, dep.version, true);
}
};
const validationErrors = [];
const validPackagesToProcess = packagesToProcess.filter((pkgInput) => {
let name = '';
if (typeof pkgInput === 'string') {
const atIdx = pkgInput.lastIndexOf('@');
if (pkgInput.startsWith('@')) {
const secondAt = pkgInput.indexOf('@', 1);
if (secondAt > 0) {
name = pkgInput.slice(0, secondAt);
}
else {
name = pkgInput;
}
}
else {
if (atIdx > 0) {
name = pkgInput.slice(0, atIdx);
}
else {
name = pkgInput;
}
}
}
else {
return false; // Type check handled before basically or skipped
}
if (!isValidNpmPackageName(name)) {
validationErrors.push({
package: pkgInput,
status: 'error',
error: 'Invalid package name format',
data: null,
message: `The package name "${name}" is invalid/malformed.`,
});
return false;
}
return true;
});
await Promise.all(validPackagesToProcess.map(async (pkgInput) => {
let name = '';
let version = 'latest';
if (typeof pkgInput === 'string') {
const atIdx = pkgInput.lastIndexOf('@');
if (pkgInput.startsWith('@')) {
const secondAt = pkgInput.indexOf('@', 1);
if (secondAt > 0) {
name = pkgInput.slice(0, secondAt);
version = pkgInput.slice(secondAt + 1);
}
else {
name = pkgInput;
}
}
else {
if (atIdx > 0) {
name = pkgInput.slice(0, atIdx);
version = pkgInput.slice(atIdx + 1);
}
else {
name = pkgInput;
}
}
}
// Resolve 'latest' to actual version number for the root package
if (version === 'latest') {
const resolved = await resolveLatestVersion(name);
if (resolved) {