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,194 lines (1,193 loc) 149 kB
#!/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;