Skip to main content
Glama

NPM Sentinel MCP

#!/usr/bin/env node import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.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 interface CacheEntry<T> { data: T; expiresAt: number; } const apiCache = new Map<string, CacheEntry<any>>(); function generateCacheKey( toolName: string, ...args: (string | number | boolean | undefined | null)[] ): string { // Simple key generation, ensure consistent order and stringification of args return `${toolName}:${args.map((arg) => String(arg)).join(':')}`; } function cacheGet<T>(key: string): T | undefined { const entry = apiCache.get(key); if (entry && entry.expiresAt > Date.now()) { return entry.data as T; } if (entry && entry.expiresAt <= Date.now()) { apiCache.delete(key); // Remove stale entry } return undefined; } function cacheSet<T>(key: string, value: T, ttlMilliseconds: number): void { 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(), }); // Updated interface for npms.io response interface NpmsApiResponse { analyzedAt: string; collected: { metadata: { name: string; version: string; description?: string; }; npm: { downloads: Array<{ from: string; to: string; count: number; }>; starsCount: number; }; github?: { starsCount: number; forksCount: number; subscribersCount: number; issues: { count: number; openCount: number; }; }; }; score: { final: number; detail: { quality: number; popularity: number; maintenance: number; }; }; } function isValidNpmsResponse(data: unknown): data is NpmsApiResponse { if (typeof data !== 'object' || data === null) { console.debug('NpmsApiResponse validation: Response is not an object or is null'); return false; } const response = data as Partial<NpmsApiResponse>; // 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(); // Type inference export type NpmPackageInfo = z.infer<typeof NpmPackageInfoSchema>; export type NpmPackageData = z.infer<typeof NpmPackageDataSchema>; export type BundlephobiaData = z.infer<typeof BundlephobiaDataSchema>; export type NpmDownloadsData = z.infer<typeof NpmDownloadsDataSchema>; // Logger function that uses stderr - only for critical errors const log = (...args: any[]) => { // 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: unknown): data is NpmPackageInfo { return ( typeof data === 'object' && data !== null && (!('maintainers' in data) || (Array.isArray((data as NpmPackageInfo).maintainers) && ((data as NpmPackageInfo).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: unknown): data is z.infer<typeof NpmPackageDataSchema> { try { return NpmPackageDataSchema.parse(data) !== null; } catch { return false; } } function isBundlephobiaData(data: unknown): data is z.infer<typeof BundlephobiaDataSchema> { try { return BundlephobiaDataSchema.parse(data) !== null; } catch { return false; } } function isNpmDownloadsData(data: unknown): data is z.infer<typeof NpmDownloadsDataSchema> { try { return NpmDownloadsDataSchema.parse(data) !== null; } catch { return false; } } export async function handleNpmVersions(args: { packages: string[]; }): Promise<CallToolResult> { 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<any>(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, }; } } interface NpmLatestVersionResponse { version: string; description?: string; author?: { name?: string; }; license?: string; homepage?: string; } export async function handleNpmLatest(args: { packages: string[]; }): Promise<CallToolResult> { 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<any>(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 as any)?.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: { packages: string[]; }): Promise<CallToolResult> { 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<any>(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: Record<string, string> | undefined) => { 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: { packages: string[] }): Promise<CallToolResult> { 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<any>(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()) as NpmPackageData; 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: any = { 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()) as NpmPackageData; 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: { packages: string[]; }): Promise<CallToolResult> { 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<any>(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: any = 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 as BundlephobiaData; const sizeData = { name: (typedRawData as any).name || name, version: (typedRawData as any).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: { packages: string[]; }): Promise<CallToolResult> { 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: string | undefined = 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 as any).name; version = (pkgInput as any).version; } const packageNameForOutput = version ? `${name}@${version}` : name; const cacheKey = generateCacheKey('handleNpmVulnerabilities', name, version || 'all'); const cachedData = cacheGet<any>(cacheKey); if (cachedData) { return { package: packageNameForOutput, versionQueried: version || null, status: 'success_cache', vulnerabilities: cachedData.vulnerabilities, message: `${cachedData.message} (from cache)`, }; } const osvBody: any = { 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' as const, 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()) as { vulns?: Array<{ id?: string; summary: string; severity?: string | { type?: string; score?: number }; references?: Array<{ url: string }>; affected?: Array<{ package?: { ecosystem: string; name: string }; ranges?: Array<{ type: string; events: Array<{ introduced?: string; fixed?: string; limit?: string }>; }>; versions?: string[]; }>; }>; }; const vulns = data.vulns || []; let message: string; 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: any[] = []; const affectedVersionsListed: string[] = []; const vulnerabilityDetails: any = { summary: vuln.summary, severity: sev, references: refs, }; if (vuln.affected && vuln.affected.length > 0) { const lifecycle: { introduced?: string; fixed?: string } = {}; 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' as const, 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: { packages: string[]; period?: 'last-week' | 'last-month' | 'last-year'; }): Promise<CallToolResult> { 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' as const, error: 'Invalid package input type', data: null, }; } if (!name) { return { packageInput: pkgInput, packageName: 'empty_package_name', status: 'error' as const, error: 'Empty package name derived from input', data: null, }; } const cacheKey = generateCacheKey('handleNpmTrends', name, period); const cachedData = cacheGet<any>(cacheKey); if (cachedData) { return { packageInput: pkgInput, packageName: name, status: 'success_cache' as const, 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' as const, error: errorMsg, data: null, }; } const data = await response.json(); if (!isNpmDownloadsData(data)) { return { packageInput: pkgInput, packageName: name, status: 'error' as const, 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' as const, error: null, data: trendData, message: `Successfully fetched download trends for ${name} (${period}).`, }; } catch (error) { return { packageInput: pkgInput, packageName: name, status: 'error' as const, 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; } } const summary = { totalPackagesProcessed: packagesToProcess.length, totalSuccessful: totalSuccessful, totalFailed: packagesToProcess.length - totalSuccessful, overallTotalDownloads: overallTotalDownloads, overallAverageDailyDownloads: totalSuccessful > 0 ? Math.round(overallTotalDownloads / daysInPeriod / totalSuccessful) : 0, }; const finalResponse = { query: { packagesInput: args.packages, periodUsed: period, }, results: processedResults, summary: summary, }; const responseJson = JSON.stringify(finalResponse, null, 2); return { content: [{ type: 'text', text: responseJson }], isError: false }; } catch (error) { const errorResponse = JSON.stringify( { query: { packagesInput: args.packages, periodUsed: args.period || 'last-month' }, results: [], summary: null, error: `General error fetching download trends: ${error instanceof Error ? error.message : 'Unknown error'}`, }, null, 2, ); return { content: [{ type: 'text', text: errorResponse }], isError: true, }; } } export async function handleNpmCompare(args: { packages: string[] }): Promise<CallToolResult> { try { const packagesToProcess = args.packages || []; if (packagesToProcess.length === 0) { throw new Error('No package names provided for comparison.'); } const processedResults = await Promise.all( packagesToProcess.map(async (pkgInput) => { let name = ''; let versionTag = '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' as const, error: 'Invalid package input type', data: null, }; } if (!name) { return { packageInput: pkgInput, packageName: 'empty_package_name', versionQueried: versionTag, status: 'error' as const, error: 'Empty package name derived from input', data: null, }; } const cacheKey = generateCacheKey('handleNpmCompare', name, versionTag); const cachedData = cacheGet<any>(cacheKey); if (cachedData) { return { packageInput: pkgInput, packageName: name, // Or cachedData.name if preferred versionQueried: versionTag, status: 'success_cache' as const, error: null, data: cachedData, message: `Comparison data for ${name}@${versionTag} from cache.`, }; } try { // Fetch package version details from registry const pkgResponse = await fetch(`https://registry.npmjs.org/${name}/${versionTag}`); if (!pkgResponse.ok) { throw new Error( `Failed to fetch package info for ${name}@${versionTag}: ${pkgResponse.status} ${pkgResponse.statusText}`, ); } const pkgData = await pkgResponse.json(); if (!isNpmPackageVersionData(pkgData)) { throw new Error(`Invalid package data format for ${name}@${versionTag}`); } // Fetch monthly downloads let monthlyDownloads: number | null = null; try { const downloadsResponse = await fetch( `https://api.npmjs.org/downloads/point/last-month/${name}`, ); if (downloadsResponse.ok) { const downloadsData = await downloadsResponse.json(); if (isNpmDownloadsData(downloadsData)) { monthlyDownloads = downloadsData.downloads; } } } catch (dlError) { console.debug(`Could not fetch downloads for ${name}: ${dlError}`); } // Fetch publish date for this specific version // Need to fetch the full package info to get to the 'time' field for specific version let publishDate: string | null = null; try { const fullPkgInfoResponse = await fetch(`https://registry.npmjs.org/${name}`); if (fullPkgInfoResponse.ok) { const fullPkgInfo = await fullPkgInfoResponse.json(); if (isNpmPackageInfo(fullPkgInfo) && fullPkgInfo.time) { publishDate = fullPkgInfo.time[pkgData.version] || null; } } } catch (timeError) { console.debug(`Could not fetch time info for ${name}: ${timeError}`); } const comparisonData = { name: pkgData.name, version: pkgData.version, description: pkgData.description || null, license: pkgData.license || null, dependenciesCount: Object.keys(pkgData.dependencies || {}).length, devDependenciesCount: Object.keys(pkgData.devDependencies || {}).length, peerDependenciesCount: Object.keys(pkgData.peerDependencies || {}).length, monthlyDownloads: monthlyDownloads, publishDate: publishDate, repositoryUrl: pkgData.repository?.url || null, }; cacheSet(cacheKey, comparisonData, CACHE_TTL_MEDIUM); return { packageInput: pkgInput, packageName: name, // or comparisonData.name versionQueried: versionTag, status: 'success' as const, error: null, data: comparisonData, message: `Successfully fetched comparison data for ${name}@${pkgData.version}.`, }; } catch (error) { return { packageInput: pkgInput, packageName: name, versionQueried: versionTag, status: 'error' as const, error: error instanceof Error ? error.message : 'Unknown processing error', data: null, }; } }), ); const finalResponse = { queryPackages: args.packages, results: processedResults, message: `Comparison data for ${args.packages.length} package(s).`, }; const responseJson = JSON.stringify(finalResponse, null, 2); return { content: [{ type: 'text', text: responseJson }], isError: false }; } catch (error) { const errorResponse = JSON.stringify( { queryPackages: args.packages, results: [], error: `General error comparing packages: ${error instanceof Error ? error.message : 'Unknown error'}`, }, null, 2, ); return { content: [{ type: 'text', text: errorResponse }], isError: true, }; } } // Function to get package quality metrics export async function handleNpmQuality(args: { packages: string[]; }): Promise<CallToolResult> { try { const packagesToProcess = args.packages || []; if (packagesToProcess.length === 0) { throw new Error('No package names provided to fetch quality metrics.'); } 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; // Version is ignored by npms.io API endpoint for the main query } else { return { packageInput: JSON.stringify(pkgInput), packageName: 'unknown_package_input', status: 'error' as const, 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' as const, error: 'Empty package name derived from input', data: null, message: 'Package name could not be determined from input.', }; } const cacheKey = generateCacheKey('handleNpmQuality', name); const cachedData = cacheGet<any>(cacheKey); if (cachedData) { return { packageInput: pkgInput, packageName: name, // Or cachedData.packageName if stored differently status: 'success_cache' as const, error: null, data: cachedData, message: `Quality score for ${name} (version analyzed: ${cachedData.versionInScore}) from cache.`, }; } try { const response = await fetch( `https://api.npms.io/v2/package/${encodeURIComponent(name)}`, { headers: { Accept: 'application/json', 'User-Agent': 'NPM-Sentinel-MCP', }, }, ); if (!response.ok) { let errorMsg = `Failed to fetch quality data: ${response.status} ${response.statusText}`; if (response.status === 404) { errorMsg = `Package ${name} not found on npms.io.`; } return { packageInput: pkgInput, packageName: name, status: 'error' as const, error: errorMsg, data: null, message: `Could not retrieve quality information for ${name}.`, }; } const rawData = await response.json(); if (!isValidNpmsResponse(rawData)) { return { packageInput: pkgInput, packageName: name, status: 'error' as const, error: 'Invalid or incomplete response from npms.io API for quality data', data: null, message: `Received malformed quality data for ${name}.`, }; } const { score, collected, analyzedAt } = rawData; const qualityScore = score.detail.quality; const qualityData = { analyzedAt: analyzedAt, versionInScore: collected.metadata.version, qualityScore: qualityScore, // Detailed sub-metrics like tests, coverage, linting, types are no longer directly provided // by the npms.io v2 API in the same way. The overall quality score is the primary metric. }; const ttl = !collected.metadata.version.match(/^\d+\.\d+\.\d+$/) ? CACHE_TTL_SHORT : CACHE_TTL_LONG; cacheSet(cacheKey, qualityData, ttl); return { packageInput: pkgInput, packageName: name, status: 'success' as const, error: null, data: qualityData, message: `Successfully fetched quality score for ${name} (version analyzed: ${collected.metadata.version}).`, }; } catch (error) { return { packageInput: pkgInput, packageName: name, status: 'error' as const, error: error instanceof Error ? error.message : 'Unknown processing error', data: null, message: `An unexpected error occurred while processing quality for ${name}.`, }; } }), ); const finalResponse = { queryPackages: args.packages, results: processedResults, }; const responseJson = JSON.stringify(finalResponse, null, 2); return { content: [{ type: 'text', text: responseJson }], isError: false }; } catch (error) { const errorResponse = JSON.stringify( { queryPackages: args.packages, results: [], error: `General error fetching quality metrics: ${error instanceof Error ? error.message : 'Unknown error'}`, }, null, 2, ); return { content: [{ type: 'text', text: errorResponse }], isError: true, }; } } export async function handleNpmMaintenance(args: { packages: string[] }): Promise<CallToolResult> { try { const packagesToProcess = args.packages || []; if (packagesToProcess.length === 0) { throw new Error('No package names provided to fetch maintenance metrics.'); } 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' as const, 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' as const, error: 'Empty package name derived from input', data: null, message: 'Package name could not be determined from input.', }; } const cacheKey = generateCacheKey('handleNpmMaintenance', name); const cachedData = cacheGet<any>(cacheKey); if (cachedData) { return { packageInput: pkgInput, packageName: name, status: 'success_cache' as const, error: null, data: cachedData, message: `Maintenance score for ${name} (version analyzed: ${cachedData.versionInScore}) from cache.`, }; } try { const response = await fetch( `https://api.npms.io/v2/package/${encodeURIComponent(name)}`, { headers: { Accept: 'application/json', 'User-Agent': 'NPM-Sentinel-MCP', }, }, ); if (!response.ok) { let errorMsg = `Failed to fetch maintenance data: ${response.status} ${response.statusText}`; if (response.status === 404) { errorMsg = `Package ${name} not found on npms.io.`; } return { packageInput: pkgInput, packageName: name, status: 'error' as const, error: errorMsg, data: null, message: `Could not retrieve maintenance information for ${name}.`, }; } const rawData = await response.json(); if (!isValidNpmsResponse(rawData)) { return { packageInput: pkgInput, packageName: name, status: 'error' as const, error: 'Invalid or incomplete response from npms.io API for maintenance data', data: null, message: `Received malformed maintenance data for ${name}.`, }; } const { score, collected, analyzedAt } = rawData; const maintenanceScoreValue = score.detail.maintenance; const maintenanceData = { analyzedAt: analyzedAt, versionInScore: collected.metadata.version, maintenanceScore: maintenanceScoreValue, }; const ttl = !collected.metadata.version.match(/^\d+\.\d+\.\d+$/) ? CACHE_TTL_SHORT : CACHE_TTL_LONG; cacheSet(cacheKey, maintenanceData, ttl); return { packageInput: pkgInput, packageName: name, status: 'success' as const, error: null, data: maintenanceData, message: `Successfully fetched maintenance score for ${name} (version analyzed: ${collected.metadata.version}).`, }; } catch (error) { return { packageInput: pkgInput, packageName: name, status: 'error' as const, error: error instanceof Error ? error.message : 'Unknown processing error', data: null, message: `An unexpected error occurred while processing maintenance for ${name}.`, }; } }), ); const finalResponse = { queryPackages: args.packages, results: processedResults, }; const responseJson = JSON.stringify(finalResponse, null, 2); return { content: [{ type: 'text', text: responseJson }], isError: false }; } catch (error) { const errorResponse = JSON.stringify( { queryPackages: args.packages, results: [], error: `General error fetching maintenance metrics: ${error instanceof Error ? error.message : 'Unknown error'}`, }, null, 2, ); return { content: [{ type: 'text', text: errorResponse }], isError: true, }; } } export async function handleNpmMaintainers(args: { packages: string[]; }): Promise<CallToolResult> { try { const packagesToProcess = args.packages || []; if (packagesToProcess.length === 0) { throw new Error('No package names provided to fetch maintainers.'); } 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; // Version is ignored for maintainers } else { return { packageInput: JSON.stringify(pkgInput), packageName: 'unknown_package_input', status: 'error' as const, error: 'Invalid package input type', data: null, }; } if (!name) { return { packageInput: pkgInput, packageName: 'empty_package_name', status: 'error' as const, error: 'Empty package name derived from input', data: null, }; } const cacheKey = generateCacheKey('handleNpmMaintainers', name); const cachedData = cacheGet<any>(cacheKey); if (cachedData) { return { packageInput: pkgInput, packageName: name, status: 'success_cache' as const, error: null, data: cachedData, message: `Maintainer information for ${name} from cache.`, }; } try { const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}`); if (!response.ok) { let errorMsg = `Failed to fetch package info: ${response.status} ${response.statusText}`; if (response.status === 404) { errorMsg = `Package ${name} not found in the npm registry.`; } return { packageInput: pkgInput, packageName: name, status: 'error' as const, error: errorMsg, data: null, }; } const data = await response.json(); if (!isNpmPackageInfo(data)) { // Using NpmPackageInfoSchema as it contains maintainers return { packageInput: pkgInput, packageName: name, status: 'error' as const, error: 'Invalid package info data received from registry', data: null, }; } const maintainers = (data.maintainers || []).map((m) => ({ name: m.name, email: m.email || null, // Ensure email is null if not present url: m.url || null, // NpmMaintainerSchema has url optional })); const maintainersData = { maintainers: maintainers, maintainersCount: maintainers.length, }; cacheSet(cacheKey, maintainersData, CACHE_TTL_VERY_LONG); return { packageInput: pkgInput, packageName: name, status: 'success' as const, error: null, data: maintainersData, message: `Successfully fetched maintainer information for ${name}.`, }; } catch (error) { return { packageInput: pkgInput, packageName: name, status: 'error' as const, error: error instanceof Error ? error.message : 'Unknown processing error', data: null, }; } }), ); const finalResponse = { queryPackages: args.packages, results: processedResults, message: `Maintainer information for ${args.packages.length} package(s).`, }; const responseJson = JSON.stringify(finalResponse, null, 2); return { content: [{ type: 'text', text: responseJson }], isError: false }; } catch (error) { const errorResponse = JSON.stringify( { queryPackages: args.packages, results: [], error: `General error fetching maintainer information: ${error instanceof Error ? error.message : 'Unknown error'}`, }, null, 2, ); return { content: [{ type: 'text', text: errorResponse }], isError: true, }; } } export async function handleNpmScore(args: { packages: string[] }): Promise<CallToolResult> { try { const packagesToProcess = args.packages || []; if (packagesToProcess.length === 0) { throw new Error('No package names provided to fetch scores.'); } 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; // Version is ignored by npms.io API endpoint } else { return { packageInput: JSON.stringify(pkgInput), packageName: 'unknown_package_input', status: 'error' as const, error: 'Invalid package input type', data: null, }; } if (!name) { return { packageInput: pkgInput, packageName: 'empty_package_name', status: 'error' as const, error: 'Empty package name derived from input', data: null, }; } const cacheKey = generateCacheKey('handleNpmScore', name); const cachedData = cacheGet<any>(cacheKey); if (cachedData) { return { packageInput: pkgInput, packageName: name, status: 'success_cache' as const, error: null, data: cachedData, message: `Score data for ${name} (version analyzed: ${cachedData.versionInScore}) from cache.`, }; } try { const response = await fetch( `https://api.npms.io/v2/package/${encodeURIComponent(name)}`, ); if (!response.ok) { let errorMsg = `Failed to fetch package score: ${response.status} ${response.statusText}`; if (response.status === 404) { errorMsg = `Package ${name} not found on npms.io.`; } return { packageInput: pkgInput, packageName: name, status: 'error' as const, error: errorMsg, data: null, }; } const rawData = await response.json(); if (!isValidNpmsResponse(rawData)) { return { packageInput: pkgInput, packageName: name, status: 'error' as const, error: 'Invalid or incomplete response from npms.io API', data: null, }; } const { score, collected, analyzedAt } = rawData; const { detail } = score; // Calculate total downloads for the last month from the typically first entry in downloads array const lastMonthDownloads = collected.npm?.downloads?.find((d) => { // Heuristic: find a download period that is roughly 30 days const from = new Date(d.from); const to = new Date(d.to); const diffTime = Math.abs(to.getTime() - from.getTime()); const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); return diffDays >= 28 && diffDays <= 31; // Common range for monthly data })?.count || collected.npm?.downloads?.[0]?.count || 0; const scoreData = { analyzedAt: analyzedAt, versionInScore: collected.metadata.version, score: { final: score.final, detail: { quality: detail.quality, popularity: detail.popularity, maintenance: detail.maintenance, }, }, packageInfoFromScore: { name: collected.metadata.name, version: collected.metadata.version, description: collected.metadata.description || null, }, npmStats: { downloadsLastMonth: lastMonthDownloads, starsCount: collected.npm.starsCount, }, githubStats: collected.github ? { starsCount: collected.github.starsCount, forksCount: collected.github.forksCount, subscribersCount: collected.github.subscribersCount, issues: { count: collected.github.issues.count, openCount: collected.github.issues.openCount, }, } : null, }; const ttl = !collected.metadata.version.match(/^\d+\.\d+\.\d+$/) ? CACHE_TTL_SHORT : CACHE_TTL_LONG; cacheSet(cacheKey, scoreData, ttl); return { packageInput: pkgInput, packageName: name, status: 'success' as const, error: null, data: scoreData, message: `Successfully fetched score data for ${name} (version analyzed: ${collected.metadata.version}).`, }; } catch (error) { return { packageInput: pkgInput, packageName: name, status: 'error' as const, error: error instanceof Error ? error.message : 'Unknown processing error', data: null, }; } }), ); const finalResponse = { queryPackages: args.packages, results: processedResults, message: `Score information for ${args.packages.length} package(s).`, }; const responseJson = JSON.stringify(finalResponse, null, 2); return { content: [{ type: 'text', text: responseJson }], isError: false }; } catch (error) { const errorResponse = JSON.stringify( { queryPackages: args.packages, results: [], error: `General error fetching package scores: ${error instanceof Error ? error.message : 'Unknown error'}`, }, null, 2, ); return { content: [{ type: 'text', text: errorResponse }], isError: true, }; } } export async function handleNpmPackageReadme(args: { packages: string[]; }): Promise<CallToolResult> { try { const packagesToProcess = args.packages || []; if (packagesToProcess.length === 0) { throw new Error('No package names provided to fetch READMEs.'); } const processedResults = await Promise.all( packagesToProcess.map(async (pkgInput) => { let name = ''; let versionTag: string | undefined = undefined; // Explicitly undefined if not specified if (typeof pkgInput === 'string') { const atIdx = pkgInput.lastIndexOf('@'); if (atIdx > 0) { name = pkgInput.slice(0, atIdx); versionTag = pkgInput.slice(atIdx + 1); } else { name = pkgInput; versionTag = 'latest'; // Default to latest if no version specified } } else { return { packageInput: JSON.stringify(pkgInput), packageName: 'unknown_package_input', versionQueried: versionTag, versionFetched: null, status: 'error' as const, error: 'Invalid package input type', data: null, }; } if (!name) { return { packageInput: pkgInput, packageName: 'empty_package_name', versionQueried: versionTag, versionFetched: null, status: 'error' as const, error: 'Empty package name derived from input', data: null, }; } const cacheKey = generateCacheKey('handleNpmPackageReadme', name, versionTag); const cachedData = cacheGet<any>(cacheKey); if (cachedData) { return { packageInput: pkgInput, packageName: name, versionQueried: versionTag, versionFetched: cachedData.versionFetched, // Retrieve stored fetched version status: 'success_cache' as const, error: null, data: { readme: cachedData.readme, hasReadme: cachedData.hasReadme }, message: `README for ${name}@${cachedData.versionFetched} from cache.`, }; } try { const response = await fetch(`https://registry.npmjs.org/${name}`); if (!response.ok) { let errorMsg = `Failed to fetch package info: ${response.status} ${response.statusText}`; if (response.status === 404) { errorMsg = `Package ${name} not found.`; } return { packageInput: pkgInput, packageName: name, versionQueried: versionTag, versionFetched: null, status: 'error' as const, error: errorMsg, data: null, }; } const packageInfo = await response.json(); if (!isNpmPackageInfo(packageInfo)) { return { packageInput: pkgInput, packageName: name, versionQueried: versionTag, versionFetched: null, status: 'error' as const, error: 'Invalid package info data received', data: null, }; } const versionToUse = versionTag === 'latest' ? packageInfo['dist-tags']?.latest : versionTag; if (!versionToUse || !packageInfo.versions || !packageInfo.versions[versionToUse]) { return { packageInput: pkgInput, packageName: name, versionQueried: versionTag, versionFetched: versionToUse || null, status: 'error' as const, error: `Version ${versionToUse || 'requested'} not found or no version data available.`, data: null, }; } const versionData = packageInfo.versions[versionToUse]; // README can be in version-specific data or at the root of packageInfo const readmeContent = versionData.readme || packageInfo.readme || null; const hasReadme = !!readmeContent; const readmeResultData = { readme: readmeContent, hasReadme: hasReadme, versionFetched: versionToUse, // Store the actually fetched version }; cacheSet(cacheKey, readmeResultData, CACHE_TTL_LONG); return { packageInput: pkgInput, packageName: name, versionQueried: versionTag, versionFetched: versionToUse, status: 'success' as const, error: null, data: { readme: readmeContent, hasReadme: hasReadme }, // Return only readme and hasReadme in data field for consistency message: `Successfully fetched README for ${name}@${versionToUse}.`, }; } catch (error) { return { packageInput: pkgInput, packageName: name, versionQueried: versionTag, versionFetched: null, status: 'error' as const, error: error instanceof Error ? error.message : 'Unknown processing error', data: null, }; } }), ); const finalResponse = { queryPackages: args.packages, results: processedResults, message: `README fetching status for ${args.packages.length} package(s).`, }; const responseJson = JSON.stringify(finalResponse, null, 2); return { content: [{ type: 'text', text: responseJson }], isError: false }; } catch (error) { const errorResponse = JSON.stringify( { queryPackages: args.packages, results: [], error: `General error fetching READMEs: ${error instanceof Error ? error.message : 'Unknown error'}`, }, null, 2, ); return { content: [{ type: 'text', text: errorResponse }], isError: true, }; } } export async function handleNpmSearch(args: { query: string; limit?: number; }): Promise<CallToolResult> { try { const query = args.query; const limit = args.limit || 10; if (limit < 1 || limit > 250) { // NPM API search limit is typically 250 throw new Error('Limit must be between 1 and 250.'); } const cacheKey = generateCacheKey('handleNpmSearch', query, limit); const cachedData = cacheGet<any>(cacheKey); if (cachedData) { const cachedResponseJson = JSON.stringify(cachedData, null, 2); return { content: [{ type: 'text', text: cachedResponseJson }], isError: false, message: `Search results for query '${query}' with limit ${limit} from cache.`, }; } const response = await fetch( `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(query)}&size=${limit}`, ); if (!response.ok) { throw new Error(`Failed to search packages: ${response.status} ${response.statusText}`); } const rawData = await response.json(); const parseResult = NpmSearchResultSchema.safeParse(rawData); if (!parseResult.success) { console.error('Invalid search results data received:', parseResult.error.issues); throw new Error('Invalid search results data received from NPM registry.'); } const { objects, total } = parseResult.data; const resultsData = objects.map((result) => { const pkg = result.package; const scoreDetail = result.score.detail; return { name: pkg.name, version: pkg.version, description: pkg.description || null, keywords: pkg.keywords || [], publisher: pkg.publisher ? { username: pkg.publisher.username, email: (pkg.publisher as any).email || null } : null, // publisher might not have email date: pkg.date || null, links: { npm: pkg.links?.npm || null, homepage: pkg.links?.homepage || null, repository: pkg.links?.repository || null, bugs: pkg.links?.bugs || null, // NpmSearchResultSchema needs to be updated if bugs is not there }, score: { final: result.score.final, detail: { quality: scoreDetail.quality, popularity: scoreDetail.popularity, maintenance: scoreDetail.maintenance, }, }, searchScore: result.searchScore, }; }); const finalResponse = { query: query, limitUsed: limit, totalResults: total, resultsCount: resultsData.length, results: resultsData, message: `Search completed. Found ${total} total packages, returning ${resultsData.length}.`, }; cacheSet(cacheKey, finalResponse, CACHE_TTL_MEDIUM); const responseJson = JSON.stringify(finalResponse, null, 2); return { content: [{ type: 'text', text: responseJson }], isError: false }; } catch (error) { const errorResponse = JSON.stringify( { query: args.query, limitUsed: args.limit || 10, totalResults: 0, resultsCount: 0, results: [], error: `Error searching packages: ${error instanceof Error ? error.message : 'Unknown error'}`, }, null, 2, ); return { content: [{ type: 'text', text: errorResponse }], isError: true, }; } } // License compatibility checker export async function handleNpmLicenseCompatibility(args: { packages: string[]; }): Promise<CallToolResult> { try { const packagesToProcess = args.packages || []; if (packagesToProcess.length === 0) { throw new Error('No package names provided for license compatibility analysis.'); } const processedResults = await Promise.all( packagesToProcess.map(async (pkgInput) => { let name = ''; let versionTag = '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, versionFetched: null, status: 'error' as const, error: 'Invalid package input type', data: null, }; } if (!name) { return { packageInput: pkgInput, packageName: 'empty_package_name', versionQueried: versionTag, versionFetched: null, status: 'error' as const, error: 'Empty package name derived from input', data: null, }; } const cacheKey = generateCacheKey('npmLicenseInfoForCompatibility', name, versionTag); const cachedLicenseData = cacheGet<{ license: string; versionFetched: string }>(cacheKey); if (cachedLicenseData) { return { packageInput: pkgInput, packageName: name, versionQueried: versionTag, versionFetched: cachedLicenseData.versionFetched, status: 'success_cache' as const, error: null, data: { license: cachedLicenseData.license }, message: `License info for ${name}@${cachedLicenseData.versionFetched} from cache.`, }; } try { const response = await fetch(`https://registry.npmjs.org/${name}/${versionTag}`); if (!response.ok) { let errorMsg = `Failed to fetch package info: ${response.status} ${response.statusText}`; if (response.status === 404) { errorMsg = `Package ${name}@${versionTag} not found.`; } return { packageInput: pkgInput, packageName: name, versionQueried: versionTag, versionFetched: null, status: 'error' as const, error: errorMsg, data: null, }; } const versionData = await response.json(); if (!isNpmPackageVersionData(versionData)) { return { packageInput: pkgInput, packageName: name, versionQueried: versionTag, versionFetched: null, // Could use versionData.version if partially valid status: 'error' as const, error: 'Invalid package version data format received', data: null, }; } const licenseInfoToCache = { license: versionData.license || 'UNKNOWN', // Default to UNKNOWN if null/undefined versionFetched: versionData.version, }; cacheSet(cacheKey, licenseInfoToCache, CACHE_TTL_VERY_LONG); return { packageInput: pkgInput, packageName: name, versionQueried: versionTag, versionFetched: versionData.version, status: 'success' as const, error: null, data: { license: versionData.license || 'UNKNOWN', // Default to UNKNOWN if null/undefined }, message: `Successfully fetched license info for ${name}@${versionData.version}.`, }; } catch (error) { return { packageInput: pkgInput, packageName: name, versionQueried: versionTag, versionFetched: null, status: 'error' as const, error: error instanceof Error ? error.message : 'Unknown processing error', data: null, }; } }), ); // Perform analysis based on fetched licenses const warnings: string[] = []; const licensesFound = processedResults .filter((r) => r.status === 'success' && r.data) .map((r) => r.data!.license.toUpperCase()); // Use toUpperCase for case-insensitive matching const uniqueLicenses = [...new Set(licensesFound)]; const hasGPL = uniqueLicenses.some((lic) => lic.includes('GPL')); const hasMIT = uniqueLicenses.some((lic) => lic === 'MIT'); const hasApache = uniqueLicenses.some((lic) => lic.includes('APACHE')); // Check for APACHE generally const hasUnknown = uniqueLicenses.some((lic) => lic === 'UNKNOWN'); const allSuccess = processedResults.every((r) => r.status === 'success'); if (!allSuccess) { warnings.push('Could not fetch license information for all packages.'); } if (hasUnknown && licensesFound.length > 0) { warnings.push( 'Some packages have unknown or unspecified licenses. Manual review recommended.', ); } if (hasGPL) { warnings.push('Contains GPL licensed code. Resulting work may need to be GPL licensed.'); if (hasMIT || hasApache) { warnings.push( 'Mixed GPL with potentially incompatible licenses (e.g., MIT, Apache). Review carefully for compliance.', ); } } // Further refined compatibility checks can be added here if needed let summary = 'License compatibility analysis completed.'; if (warnings.length > 0) { summary = 'License compatibility analysis completed with warnings.'; } else if (licensesFound.length === 0 && allSuccess) { summary = 'No license information found for the queried packages.'; } else if (licensesFound.length > 0 && !hasGPL && !hasUnknown) { summary = 'Licenses found appear to be generally compatible (non-GPL, known licenses).'; } const analysis = { summary: summary, warnings: warnings, uniqueLicensesFound: uniqueLicenses, }; const finalResponse = { queryPackages: args.packages, results: processedResults, analysis: analysis, message: `License compatibility check for ${args.packages.length} package(s). Note: This is a basic analysis. For legal compliance, consult a legal expert.`, }; const responseJson = JSON.stringify(finalResponse, null, 2); return { content: [{ type: 'text', text: responseJson }], isError: false }; } catch (error) { const errorResponse = JSON.stringify( { queryPackages: args.packages, results: [], analysis: null, error: `General error analyzing license compatibility: ${error instanceof Error ? error.message : 'Unknown error'}`, }, null, 2, ); return { content: [{ type: 'text', text: errorResponse }], isError: true, }; } } interface GitHubRepoStats { stargazers_count: number; forks_count: number; open_issues_count: number; watchers_count: number; updated_at: string; created_at: string; has_wiki: boolean; default_branch: string; topics: string[]; } // Repository statistics analyzer export async function handleNpmRepoStats(args: { packages: string[] }): Promise<CallToolResult> { try { const packagesToProcess = args.packages || []; if (packagesToProcess.length === 0) { throw new Error('No package names provided for repository statistics analysis.'); } 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; // Version typically ignored for repo stats } else { return { packageInput: JSON.stringify(pkgInput), packageName: 'unknown_package_input', status: 'error' as const, 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' as const, error: 'Empty package name derived from input', data: null, message: 'Package name could not be determined from input.', }; } const cacheKey = generateCacheKey('handleNpmRepoStats', name); const cachedResult = cacheGet<any>(cacheKey); // Cache stores the entire result object structure if (cachedResult) { // Return the entire cached result object, which already includes status, data, message return { ...cachedResult, // Spread the cached result packageInput: pkgInput, // Add current input for context packageName: name, // Add current name for context status: `${cachedResult.status}_cache` as const, // Append _cache to status message: `${cachedResult.message} (from cache)`, }; } try { const npmResponse = await fetch(`https://registry.npmjs.org/${name}/latest`); if (!npmResponse.ok) { const errorData = { packageInput: pkgInput, packageName: name, status: 'error' as const, error: `Failed to fetch npm info for ${name}: ${npmResponse.status} ${npmResponse.statusText}`, data: null, message: `Could not retrieve NPM package data for ${name}.`, }; // Do not cache primary API call failures return errorData; } const npmData = await npmResponse.json(); if (!isNpmPackageVersionData(npmData)) { const errorData = { packageInput: pkgInput, packageName: name, status: 'error' as const, error: 'Invalid NPM package data format received.', data: null, message: `Malformed NPM package data for ${name}.`, }; return errorData; } const repoUrl = npmData.repository?.url; if (!repoUrl) { const resultNoRepo = { packageInput: pkgInput, packageName: name, status: 'no_repo_found' as const, error: null, data: null, message: `No repository URL found in package data for ${name}.`, }; cacheSet(cacheKey, resultNoRepo, CACHE_TTL_LONG); return resultNoRepo; } const githubMatch = repoUrl.match(/github\.com[:\/]([^\/]+)\/([^\/.]+)/); if (!githubMatch) { const resultNotGitHub = { packageInput: pkgInput, packageName: name, status: 'not_github_repo' as const, error: null, data: { repositoryUrl: repoUrl }, message: `Repository URL found (${repoUrl}) is not a standard GitHub URL.`, }; cacheSet(cacheKey, resultNotGitHub, CACHE_TTL_LONG); return resultNotGitHub; } const [, owner, repo] = githubMatch; const githubRepoApiUrl = `https://api.github.com/repos/${owner}/${repo.replace(/\.git$/, '')}`; const githubResponse = await fetch(githubRepoApiUrl, { headers: { Accept: 'application/vnd.github.v3+json', 'User-Agent': 'NPM-Sentinel-MCP', }, }); if (!githubResponse.ok) { const errorData = { packageInput: pkgInput, packageName: name, status: 'error' as const, error: `Failed to fetch GitHub repo stats for ${owner}/${repo}: ${githubResponse.status} ${githubResponse.statusText}`, data: { githubRepoUrl: githubRepoApiUrl }, message: `Could not retrieve GitHub repository statistics from ${githubRepoApiUrl}.`, }; // Do not cache GitHub API call failures for now return errorData; } const githubData = (await githubResponse.json()) as GitHubRepoStats; const successResult = { packageInput: pkgInput, packageName: name, status: 'success' as const, error: null, data: { githubRepoUrl: `https://github.com/${owner}/${repo.replace(/\.git$/, '')}`, stars: githubData.stargazers_count, forks: githubData.forks_count, openIssues: githubData.open_issues_count, watchers: githubData.watchers_count, createdAt: githubData.created_at, updatedAt: githubData.updated_at, defaultBranch: githubData.default_branch, hasWiki: githubData.has_wiki, topics: githubData.topics || [], }, message: 'GitHub repository statistics fetched successfully.', }; cacheSet(cacheKey, successResult, CACHE_TTL_LONG); return successResult; } catch (error) { return { packageInput: pkgInput, packageName: name, status: 'error' as const, error: error instanceof Error ? error.message : 'Unknown processing error', data: null, message: `An unexpected error occurred while processing ${name}.`, }; } }), ); const finalResponse = { queryPackages: args.packages, results: processedResults, message: `Repository statistics analysis for ${args.packages.length} package(s).`, }; const responseJson = JSON.stringify(finalResponse, null, 2); return { content: [{ type: 'text', text: responseJson }], isError: false }; } catch (error) { const errorResponse = JSON.stringify( { queryPackages: args.packages, results: [], error: `General error analyzing repository stats: ${error instanceof Error ? error.message : 'Unknown error'}`, }, null, 2, ); return { content: [{ type: 'text', text: errorResponse }], isError: true, }; } } interface NpmDependencies { dependencies?: Record<string, string>; devDependencies?: Record<string, string>; peerDependencies?: Record<string, string>; } interface DeprecatedDependency { name: string; version: string; message: string; } interface GithubRelease { tag_name?: string; name?: string; published_at?: string; } interface NpmSearchResponse { objects: Array<{ package: { name: string; description?: string; version: string; keywords?: string[]; date: string; links?: { repository?: string; }; }; score: { final: number; }; }>; total: number; } interface NpmPackageVersion { deprecated?: string; dependencies?: Record<string, string>; devDependencies?: Record<string, string>; peerDependencies?: Record<string, string>; } interface NpmRegistryResponse { versions?: Record<string, NpmPackageVersion>; 'dist-tags'?: { latest?: string }; // Ensure dist-tags is properly typed for schema validation readme?: string; // Add readme for NpmPackageInfo compatibility } interface DownloadCount { downloads: number; } export async function handleNpmDeprecated(args: { packages: string[] }): Promise<CallToolResult> { 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 initialPackageNameForOutput = version === 'latest' ? name : `${name}@${version}`; const cacheKey = generateCacheKey('handleNpmDeprecated', name, version); const cachedResult = cacheGet<any>(cacheKey); if (cachedResult) { // console.debug(`[handleNpmDeprecated] Cache hit for ${cacheKey}`); return { package: cachedResult.package, status: 'success_cache', error: null, data: cachedResult.data, message: `${cachedResult.message} (from cache)`, }; } // console.debug(`[handleNpmDeprecated] Cache miss for ${cacheKey}`); try { const mainPkgResponse = await fetch(`https://registry.npmjs.org/${name}`); if (!mainPkgResponse.ok) { return { package: initialPackageNameForOutput, status: 'error', error: `Failed to fetch package info for ${name}: ${mainPkgResponse.status} ${mainPkgResponse.statusText}`, data: null, message: `Could not retrieve main package data for ${name}.`, }; } const mainPkgData = (await mainPkgResponse.json()) as NpmRegistryResponse; let versionToFetch = version; if (version === 'latest') { versionToFetch = mainPkgData['dist-tags']?.latest || 'latest'; if (versionToFetch === 'latest' && !mainPkgData.versions?.[versionToFetch]) { const availableVersions = Object.keys(mainPkgData.versions || {}); if (availableVersions.length > 0) { versionToFetch = availableVersions.sort().pop() || 'latest'; // Basic sort } } } const finalPackageNameForOutput = `${name}@${versionToFetch}`; const versionInfo = mainPkgData.versions?.[versionToFetch]; if (!versionInfo) { return { package: finalPackageNameForOutput, status: 'error', error: `Version ${versionToFetch} not found for package ${name}.`, data: null, message: `Specified version for ${name} does not exist.`, }; } const isPackageDeprecated = !!versionInfo.deprecated; const packageDeprecationMessage = versionInfo.deprecated || null; const processDependencies = async ( deps: Record<string, string> | undefined, ): Promise< Array<{ name: string; version: string; lookedUpAs: string; isDeprecated: boolean; deprecationMessage: string | null; statusMessage: string; }> > => { if (!deps) return []; const depChecks = Object.entries(deps).map(async ([depName, depSemVer]) => { const lookedUpAs = depName; // Strategy: always use original name, no cleaning. let statusMessage = ''; try { // console.debug(`[handleNpmDeprecated] Checking dependency: ${depName}@${depSemVer}`); const depInfoResponse = await fetch( `https://registry.npmjs.org/${encodeURIComponent(depName)}`, ); if (!depInfoResponse.ok) { statusMessage = `Could not fetch dependency info for '${depName}' (status: ${depInfoResponse.status}). Deprecation status unknown.`; // console.warn(`[handleNpmDeprecated] ${statusMessage}`); return { name: depName, version: depSemVer, lookedUpAs: lookedUpAs, isDeprecated: false, // Assume not deprecated as status is unknown deprecationMessage: null, statusMessage: statusMessage, }; } const depData = (await depInfoResponse.json()) as NpmRegistryResponse; const latestDepVersionTag = depData['dist-tags']?.latest; const latestDepVersionInfo = latestDepVersionTag ? depData.versions?.[latestDepVersionTag] : undefined; statusMessage = `Successfully checked '${depName}'.`; return { name: depName, version: depSemVer, lookedUpAs: lookedUpAs, isDeprecated: !!latestDepVersionInfo?.deprecated, deprecationMessage: latestDepVersionInfo?.deprecated || null, statusMessage: statusMessage, }; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown processing error'; statusMessage = `Error processing dependency '${depName}': ${errorMessage}. Deprecation status unknown.`; // console.warn(`[handleNpmDeprecated] ${statusMessage}`); return { name: depName, version: depSemVer, lookedUpAs: lookedUpAs, isDeprecated: false, // Assume not deprecated as status is unknown deprecationMessage: null, statusMessage: statusMessage, }; } }); return Promise.all(depChecks); }; const directDeps = await processDependencies(versionInfo.dependencies); const devDeps = await processDependencies(versionInfo.devDependencies); const peerDeps = await processDependencies(versionInfo.peerDependencies); const allDeps = [...directDeps, ...devDeps, ...peerDeps]; const unverifiableDepsCount = allDeps.filter((dep) => { const msg = dep.statusMessage.toLowerCase(); return msg.includes('could not fetch') || msg.includes('error processing'); }).length; let dependencySummaryMessage = `Processed ${allDeps.length} total dependencies.`; if (unverifiableDepsCount > 0) { dependencySummaryMessage += ` Could not verify the status for ${unverifiableDepsCount} dependencies (e.g., package name not found in registry or network issues). Their deprecation status is unknown.`; } const resultData = { isPackageDeprecated, packageDeprecationMessage, dependencies: { direct: directDeps, development: devDeps, peer: peerDeps, }, dependencySummary: { totalDependencies: allDeps.length, unverifiableDependencies: unverifiableDepsCount, message: dependencySummaryMessage, }, }; const fullMessage = `Deprecation status for ${finalPackageNameForOutput}. ${dependencySummaryMessage}`; const resultToCache = { package: finalPackageNameForOutput, data: resultData, message: fullMessage, }; cacheSet(cacheKey, resultToCache, CACHE_TTL_MEDIUM); // console.debug(`[handleNpmDeprecated] Set cache for ${cacheKey}`); return { package: finalPackageNameForOutput, status: 'success', error: null, data: resultData, message: fullMessage, }; } catch (error) { // console.error(`[handleNpmDeprecated] Error processing ${initialPackageNameForOutput}: ${error}`); return { package: initialPackageNameForOutput, status: 'error', error: error instanceof Error ? error.message : 'Unknown processing error', data: null, message: `An unexpected error occurred while processing ${initialPackageNameForOutput}.`, }; } }), ); const responseJson = JSON.stringify({ results: processedResults }, null, 2); return { content: [{ type: 'text', text: responseJson }], isError: false }; } catch (error) { // console.error(`[handleNpmDeprecated] General error: ${error}`); const errorResponse = JSON.stringify( { results: [], error: `General error checking deprecated packages: ${error instanceof Error ? error.message : 'Unknown error'}`, }, null, 2, ); return { content: [{ type: 'text', text: errorResponse }], isError: true, }; } } export async function handleNpmChangelogAnalysis(args: { packages: string[]; }): Promise<CallToolResult> { try { const packagesToProcess = args.packages || []; if (packagesToProcess.length === 0) { throw new Error('No package names provided for changelog analysis.'); } const processedResults = await Promise.all( packagesToProcess.map(async (pkgInput) => { let name = ''; let versionQueried: string | undefined = undefined; if (typeof pkgInput === 'string') { const atIdx = pkgInput.lastIndexOf('@'); if (atIdx > 0) { name = pkgInput.slice(0, atIdx); versionQueried = pkgInput.slice(atIdx + 1); } else { name = pkgInput; } } else { return { packageInput: JSON.stringify(pkgInput), packageName: 'unknown_package_input', versionQueried: versionQueried, status: 'error' as const, error: 'Invalid package input type', data: null, message: 'Package input was not a string.', }; } if (!name) { return { packageInput: pkgInput, packageName: 'empty_package_name', versionQueried: versionQueried, status: 'error' as const, error: 'Empty package name derived from input', data: null, message: 'Package name could not be determined from input.', }; } const cacheKey = generateCacheKey('handleNpmChangelogAnalysis', name); const cachedResult = cacheGet<any>(cacheKey); // Expects the full result object to be cached if (cachedResult) { return { ...cachedResult, packageInput: pkgInput, // Ensure these are current for this specific call packageName: name, versionQueried: versionQueried, status: `${cachedResult.status}_cache` as const, message: `${cachedResult.message} (from cache)`, }; } try { const npmResponse = await fetch(`https://registry.npmjs.org/${name}`); if (!npmResponse.ok) { const errorResult = { packageInput: pkgInput, packageName: name, versionQueried: versionQueried, status: 'error' as const, error: `Failed to fetch npm info for ${name}: ${npmResponse.status} ${npmResponse.statusText}`, data: null, message: `Could not retrieve NPM package data for ${name}.`, }; return errorResult; // Do not cache this type of error } const npmData = await npmResponse.json(); if (!isNpmPackageInfo(npmData)) { const errorResult = { packageInput: pkgInput, packageName: name, versionQueried: versionQueried, status: 'error' as const, error: 'Invalid NPM package info data received', data: null, message: `Received malformed NPM package data for ${name}.`, }; return errorResult; // Do not cache this type of error } const repositoryUrl = npmData.repository?.url; if (!repositoryUrl) { const resultNoRepo = { packageInput: pkgInput, packageName: name, versionQueried: versionQueried, status: 'no_repo_found' as const, error: null, data: null, message: `No repository URL found in package data for ${name}.`, }; cacheSet(cacheKey, resultNoRepo, CACHE_TTL_MEDIUM); return resultNoRepo; } const githubMatch = repositoryUrl.match(/github\.com[:\/]([^\/]+)\/([^\/.]+)/); if (!githubMatch) { const resultNotGitHub = { packageInput: pkgInput, packageName: name, versionQueried: versionQueried, status: 'not_github_repo' as const, error: null, data: { repositoryUrl: repositoryUrl }, message: `Repository URL (${repositoryUrl}) is not a standard GitHub URL.`, }; cacheSet(cacheKey, resultNotGitHub, CACHE_TTL_MEDIUM); return resultNotGitHub; } const [, owner, repo] = githubMatch; const repoNameForUrl = repo.replace(/\.git$/, ''); const changelogFiles = [ 'CHANGELOG.md', 'changelog.md', 'CHANGES.md', 'changes.md', 'HISTORY.md', 'history.md', 'NEWS.md', 'news.md', 'RELEASES.md', 'releases.md', ]; let changelogContent: string | null = null; let changelogSourceUrl: string | null = null; let hasChangelogFile = false; for (const file of changelogFiles) { try { const rawChangelogUrl = `https://raw.githubusercontent.com/${owner}/${repoNameForUrl}/master/${file}`; const response = await fetch(rawChangelogUrl); if (response.ok) { changelogContent = await response.text(); changelogSourceUrl = rawChangelogUrl; hasChangelogFile = true; break; } } catch (error) { console.debug(`Error fetching changelog file ${file} for ${name}: ${error}`); } } let githubReleases: any[] = []; try { const githubApiResponse = await fetch( `https://api.github.com/repos/${owner}/${repoNameForUrl}/releases?per_page=5`, { headers: { Accept: 'application/vnd.github.v3+json', 'User-Agent': 'NPM-Sentinel-MCP', }, }, ); if (githubApiResponse.ok) { const releasesData = (await githubApiResponse.json()) as GithubRelease[]; githubReleases = releasesData.map((r) => ({ tag_name: r.tag_name || null, name: r.name || null, published_at: r.published_at || null, })); } } catch (error) { console.debug(`Error fetching GitHub releases for ${name}: ${error}`); } const versions = Object.keys(npmData.versions || {}); const npmVersionHistory = { totalVersions: versions.length, latestVersion: npmData['dist-tags']?.latest || (versions.length > 0 ? versions.sort().pop() : null), firstVersion: versions.length > 0 ? versions.sort()[0] : null, }; const status = changelogContent || githubReleases.length > 0 ? 'success' : 'no_changelog_found'; const message = status === 'success' ? `Changelog and release information retrieved for ${name}.` : status === 'no_changelog_found' ? `No changelog file or GitHub releases found for ${name}.` : `Changelog analysis for ${name}.`; const resultToCache = { packageInput: pkgInput, // This might differ on subsequent cache hits, so store the original reference for this specific cache entry packageName: name, versionQueried: versionQueried, status: status as 'success' | 'no_changelog_found', error: null, data: { repositoryUrl: repositoryUrl, changelogSourceUrl: changelogSourceUrl, changelogContent: changelogContent ? `${changelogContent.split('\n').slice(0, 50).join('\n')}...` : null, hasChangelogFile: hasChangelogFile, githubReleases: githubReleases, npmVersionHistory: npmVersionHistory, }, message: message, }; cacheSet(cacheKey, resultToCache, CACHE_TTL_MEDIUM); return resultToCache; } catch (error) { const errorResult = { packageInput: pkgInput, packageName: name, versionQueried: versionQueried, status: 'error' as const, error: error instanceof Error ? error.message : 'Unknown processing error', data: null, message: `An unexpected error occurred while analyzing changelog for ${name}.`, }; return errorResult; // Do not cache general errors } }), ); const finalResponse = { queryPackages: args.packages, results: processedResults, }; const responseJson = JSON.stringify(finalResponse, null, 2); return { content: [{ type: 'text', text: responseJson }], isError: false }; } catch (error) { const errorResponse = JSON.stringify( { queryPackages: args.packages, results: [], error: `General error analyzing changelogs: ${error instanceof Error ? error.message : 'Unknown error'}`, }, null, 2, ); return { content: [{ type: 'text', text: errorResponse }], isError: true, }; } } export async function handleNpmAlternatives(args: { packages: string[] }): Promise<CallToolResult> { try { const packagesToProcess = args.packages || []; if (packagesToProcess.length === 0) { throw new Error('No package names provided to find alternatives.'); } const processedResults = await Promise.all( packagesToProcess.map(async (pkgInput) => { let originalPackageName = ''; let versionQueried: string | undefined = undefined; if (typeof pkgInput === 'string') { const atIdx = pkgInput.lastIndexOf('@'); if (atIdx > 0) { originalPackageName = pkgInput.slice(0, atIdx); versionQueried = pkgInput.slice(atIdx + 1); } else { originalPackageName = pkgInput; } } else { return { packageInput: JSON.stringify(pkgInput), packageName: 'unknown_package_input', status: 'error' as const, error: 'Invalid package input type', data: null, message: 'Package input was not a string.', }; } if (!originalPackageName) { return { packageInput: pkgInput, packageName: 'empty_package_name', status: 'error' as const, error: 'Empty package name derived from input', data: null, message: 'Package name could not be determined from input.', }; } const cacheKey = generateCacheKey('handleNpmAlternatives', originalPackageName); const cachedResult = cacheGet<any>(cacheKey); // Expects the full result object if (cachedResult) { return { ...cachedResult, packageInput: pkgInput, // current input context packageName: originalPackageName, // current name context // versionQueried is part of cachedResult.data or similar if stored, or add if needed status: `${cachedResult.status}_cache` as const, message: `${cachedResult.message} (from cache)`, }; } try { const searchResponse = await fetch( `https://registry.npmjs.org/-/v1/search?text=keywords:${encodeURIComponent( originalPackageName, )}&size=10`, ); if (!searchResponse.ok) { const errorResult = { packageInput: pkgInput, packageName: originalPackageName, status: 'error' as const, error: `Failed to search for alternatives: ${searchResponse.status} ${searchResponse.statusText}`, data: null, message: 'Could not perform search for alternatives.', }; return errorResult; // Do not cache API errors for search } const searchData = (await searchResponse.json()) as NpmSearchResponse; const alternativePackagesRaw = searchData.objects || []; let originalPackageDownloads = 0; try { const dlResponse = await fetch( `https://api.npmjs.org/downloads/point/last-month/${originalPackageName}`, ); if (dlResponse.ok) { originalPackageDownloads = ((await dlResponse.json()) as DownloadCount).downloads || 0; } } catch (e) { console.debug( `Failed to fetch downloads for original package ${originalPackageName}: ${e}`, ); } const originalPackageKeywords = alternativePackagesRaw.find((p) => p.package.name === originalPackageName)?.package .keywords || []; const originalPackageStats = { name: originalPackageName, monthlyDownloads: originalPackageDownloads, keywords: originalPackageKeywords, }; if ( alternativePackagesRaw.length === 0 || (alternativePackagesRaw.length === 1 && alternativePackagesRaw[0].package.name === originalPackageName) ) { const resultNoAlternatives = { packageInput: pkgInput, packageName: originalPackageName, status: 'no_alternatives_found' as const, error: null, data: { originalPackageStats, alternatives: [] }, message: `No significant alternatives found for ${originalPackageName} based on keyword search.`, }; cacheSet(cacheKey, resultNoAlternatives, CACHE_TTL_MEDIUM); return resultNoAlternatives; } const alternativesData = await Promise.all( alternativePackagesRaw .filter((alt) => alt.package.name !== originalPackageName) .slice(0, 5) .map(async (alt) => { let altDownloads = 0; try { const altDlResponse = await fetch( `https://api.npmjs.org/downloads/point/last-month/${alt.package.name}`, ); if (altDlResponse.ok) { altDownloads = ((await altDlResponse.json()) as DownloadCount).downloads || 0; } } catch (e) { console.debug( `Failed to fetch downloads for alternative ${alt.package.name}: ${e}`, ); } return { name: alt.package.name, description: alt.package.description || null, version: alt.package.version, monthlyDownloads: altDownloads, score: alt.score.final, repositoryUrl: alt.package.links?.repository || null, keywords: alt.package.keywords || [], }; }), ); const successResult = { packageInput: pkgInput, packageName: originalPackageName, status: 'success' as const, error: null, data: { originalPackageStats: originalPackageStats, alternatives: alternativesData, }, message: `Found ${alternativesData.length} alternative(s) for ${originalPackageName}.`, }; cacheSet(cacheKey, successResult, CACHE_TTL_MEDIUM); return successResult; } catch (error) { const errorResult = { packageInput: pkgInput, packageName: originalPackageName, status: 'error' as const, error: error instanceof Error ? error.message : 'Unknown processing error', data: null, message: `An unexpected error occurred while finding alternatives for ${originalPackageName}.`, }; return errorResult; // Do not cache general errors } }), ); const finalResponse = { queryPackages: args.packages, results: processedResults, }; const responseJson = JSON.stringify(finalResponse, null, 2); return { content: [{ type: 'text', text: responseJson }], isError: false }; } catch (error) { const errorResponse = JSON.stringify( { queryPackages: args.packages, results: [], error: `General error finding alternatives: ${error instanceof Error ? error.message : 'Unknown error'}`, }, null, 2, ); return { content: [{ type: 'text', text: errorResponse }], isError: true, }; } } // Get __dirname in an ES module environment const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // The package root will be one level above __dirname (which is 'dist/' after compilation) const packageRoot = path.join(__dirname, '..'); // Create server instance const server = new McpServer({ name: 'npm-sentinel-mcp', version: '1.7.8', capabilities: { resources: {}, }, }); // Update paths to be relative to the package const README_PATH = path.join(packageRoot, 'README.md'); const LLMS_FULL_TEXT_PATH = path.join(packageRoot, 'llms-full.txt'); // Register README.md resource server.resource( 'serverReadme', 'doc://server/readme', { name: 'Server README', description: 'Main documentation and usage guide for this NPM Info Server.', mimeType: 'text/markdown', }, async (uri: URL): Promise<{ contents: { uri: string; text: string; mimeType: string }[] }> => { try { const readmeContent = fs.readFileSync(README_PATH, 'utf-8'); return { contents: [ { uri: uri.href, text: readmeContent, mimeType: 'text/markdown', }, ], }; } catch (error: any) { console.error(`Error reading README.md for resource ${uri.href}:`, error.message); throw { code: -32002, message: `Resource not found or unreadable: ${uri.href}`, data: { uri: uri.href, cause: error.message }, }; } }, ); // Register llms-full.txt resource (MCP Specification) server.resource( 'mcpSpecification', 'doc://mcp/specification', { name: 'MCP Full Specification', description: 'The llms-full.txt content providing a comprehensive overview of the Model Context Protocol.', mimeType: 'text/plain', }, async (uri: URL): Promise<{ contents: { uri: string; text: string; mimeType: string }[] }> => { try { const specContent = fs.readFileSync(LLMS_FULL_TEXT_PATH, 'utf-8'); return { contents: [ { uri: uri.href, text: specContent, mimeType: 'text/plain', }, ], }; } catch (error: any) { console.error(`Error reading llms-full.txt for resource ${uri.href}:`, error.message); throw { code: -32002, message: `Resource not found or unreadable: ${uri.href}`, data: { uri: uri.href, cause: error.message }, }; } }, ); // Add NPM tools - Ensuring each tool registration is complete and correct server.tool( 'npmVersions', 'Get all available versions of an NPM package', { packages: z.array(z.string()).describe('List of package names to get versions for'), }, { title: 'Get All Package Versions', readOnlyHint: true, openWorldHint: true, idempotentHint: true, }, async (args: { packages: string[] }) => { return await handleNpmVersions(args); }, ); server.tool( 'npmLatest', 'Get the latest version and changelog of an NPM package', { packages: z.array(z.string()).describe('List of package names to get latest versions for'), }, { title: 'Get Latest Package Information', readOnlyHint: true, openWorldHint: true, idempotentHint: true, // Result for 'latest' tag can change, but call itself is idempotent }, async (args: { packages: string[] }) => { return await handleNpmLatest(args); }, ); server.tool( 'npmDeps', 'Analyze dependencies and devDependencies of an NPM package', { packages: z.array(z.string()).describe('List of package names to analyze dependencies for'), }, { title: 'Get Package Dependencies', readOnlyHint: true, openWorldHint: true, idempotentHint: true, }, async (args: { packages: string[] }) => { return await handleNpmDeps(args); }, ); server.tool( 'npmTypes', 'Check TypeScript types availability and version for a package', { packages: z.array(z.string()).describe('List of package names to check types for'), }, { title: 'Check TypeScript Type Availability', readOnlyHint: true, openWorldHint: true, idempotentHint: true, }, async (args: { packages: string[] }) => { return await handleNpmTypes(args); }, ); server.tool( 'npmSize', 'Get package size information including dependencies and bundle size', { packages: z.array(z.string()).describe('List of package names to get size information for'), }, { title: 'Get Package Size (Bundlephobia)', readOnlyHint: true, openWorldHint: true, idempotentHint: true, }, async (args: { packages: string[] }) => { return await handleNpmSize(args); }, ); server.tool( 'npmVulnerabilities', 'Check for known vulnerabilities in packages', { packages: z.array(z.string()).describe('List of package names to check for vulnerabilities'), }, { title: 'Check Package Vulnerabilities (OSV.dev)', readOnlyHint: true, openWorldHint: true, idempotentHint: false, // Vulnerability data can change frequently }, async (args: { packages: string[] }) => { return await handleNpmVulnerabilities(args); }, ); server.tool( 'npmTrends', 'Get download trends and popularity metrics for packages', { packages: z.array(z.string()).describe('List of package names to get trends for'), period: z .enum(['last-week', 'last-month', 'last-year']) .describe('Time period for trends. Options: "last-week", "last-month", "last-year"') .optional() .default('last-month'), }, { title: 'Get NPM Package Download Trends', readOnlyHint: true, openWorldHint: true, idempotentHint: true, // Trends for a fixed past period are idempotent }, async (args: { packages: string[]; period?: 'last-week' | 'last-month' | 'last-year' }) => { return await handleNpmTrends(args); }, ); server.tool( 'npmCompare', 'Compare multiple NPM packages based on various metrics', { packages: z.array(z.string()).describe('List of package names to compare'), }, { title: 'Compare NPM Packages', readOnlyHint: true, openWorldHint: true, idempotentHint: true, }, async (args: { packages: string[] }) => { return await handleNpmCompare(args); }, ); server.tool( 'npmMaintainers', 'Get maintainers information for NPM packages', { packages: z.array(z.string()).describe('List of package names to get maintainers for'), }, { title: 'Get NPM Package Maintainers', readOnlyHint: true, openWorldHint: true, idempotentHint: true, }, async (args: { packages: string[] }) => { return await handleNpmMaintainers(args); }, ); server.tool( 'npmScore', 'Get consolidated package score based on quality, maintenance, and popularity metrics', { packages: z.array(z.string()).describe('List of package names to get scores for'), }, { title: 'Get NPM Package Score (NPMS.io)', readOnlyHint: true, openWorldHint: true, idempotentHint: true, // Score for a version is stable, for 'latest' can change }, async (args: { packages: string[] }) => { return await handleNpmScore(args); }, ); server.tool( 'npmPackageReadme', 'Get the README content for NPM packages', { packages: z.array(z.string()).describe('List of package names to get READMEs for'), }, { title: 'Get NPM Package README', readOnlyHint: true, openWorldHint: true, idempotentHint: true, }, async (args: { packages: string[] }) => { return await handleNpmPackageReadme(args); }, ); server.tool( 'npmSearch', 'Search for NPM packages with optional limit', { query: z.string().describe('Search query for packages'), limit: z .number() .min(1) .max(50) .optional() .describe('Maximum number of results to return (default: 10)'), }, { title: 'Search NPM Packages', readOnlyHint: true, openWorldHint: true, idempotentHint: false, // Search results can change }, async (args: { query: string; limit?: number }) => { return await handleNpmSearch(args); }, ); server.tool( 'npmLicenseCompatibility', 'Check license compatibility between multiple packages', { packages: z .array(z.string()) .min(1) .describe('List of package names to check for license compatibility'), }, { title: 'Check NPM License Compatibility', readOnlyHint: true, openWorldHint: true, idempotentHint: true, }, async (args: { packages: string[] }) => { return await handleNpmLicenseCompatibility(args); }, ); server.tool( 'npmRepoStats', 'Get repository statistics for NPM packages', { packages: z.array(z.string()).describe('List of package names to get repository stats for'), }, { title: 'Get NPM Package Repository Stats (GitHub)', readOnlyHint: true, openWorldHint: true, idempotentHint: true, // Stats for a repo at a point in time, though they change over time }, async (args: { packages: string[] }) => { return await handleNpmRepoStats(args); }, ); server.tool( 'npmDeprecated', 'Check if packages are deprecated', { packages: z.array(z.string()).describe('List of package names to check for deprecation'), }, { title: 'Check NPM Package Deprecation Status', readOnlyHint: true, openWorldHint: true, idempotentHint: true, // Deprecation status is generally stable for a version }, async (args: { packages: string[] }) => { return await handleNpmDeprecated(args); }, ); server.tool( 'npmChangelogAnalysis', 'Analyze changelog and release history of packages', { packages: z.array(z.string()).describe('List of package names to analyze changelogs for'), }, { title: 'Analyze NPM Package Changelog (GitHub)', readOnlyHint: true, openWorldHint: true, idempotentHint: true, }, async (args: { packages: string[] }) => { return await handleNpmChangelogAnalysis(args); }, ); server.tool( 'npmAlternatives', 'Find alternative packages with similar functionality', { packages: z.array(z.string()).describe('List of package names to find alternatives for'), }, { title: 'Find NPM Package Alternatives', readOnlyHint: true, openWorldHint: true, idempotentHint: false, // Search-based, results can change }, async (args: { packages: string[] }) => { return await handleNpmAlternatives(args); }, ); server.tool( 'npmQuality', 'Analyze package quality metrics', { packages: z.array(z.string()).describe('List of package names to analyze'), }, { title: 'Analyze NPM Package Quality (NPMS.io)', readOnlyHint: true, openWorldHint: true, idempotentHint: true, // Score for a version is stable, for 'latest' can change }, async (args: { packages: string[] }) => { return await handleNpmQuality(args); }, ); server.tool( 'npmMaintenance', 'Analyze package maintenance metrics', { packages: z.array(z.string()).describe('List of package names to analyze'), }, { title: 'Analyze NPM Package Maintenance (NPMS.io)', readOnlyHint: true, openWorldHint: true, idempotentHint: true, // Score for a version is stable, for 'latest' can change }, async (args: { packages: string[] }) => { return await handleNpmMaintenance(args); }, ); // Start receiving messages on stdin and sending messages on stdout const transport = new StdioServerTransport(); await server.connect(transport); process.stdin.on('close', () => { server.close(); }); // Handle uncaught errors process.on('uncaughtException', (error) => { console.error('Fatal error:', error); server.close(); process.exit(1); }); process.on('unhandledRejection', (error) => { console.error('Unhandled rejection:', error); server.close(); process.exit(1); }); // Type guard for NpmPackageVersionSchema function isNpmPackageVersionData(data: unknown): data is z.infer<typeof NpmPackageVersionSchema> { try { // Use safeParse for type guards to avoid throwing errors on invalid data return NpmPackageVersionSchema.safeParse(data).success; } catch (e) { // This catch block might not be strictly necessary with safeParse but kept for safety // console.error("isNpmPackageVersionData validation failed unexpectedly:", e); return false; } }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Nekzus/mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server