UNPKG

@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
#!/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) {