npmplus-mcp-server
Version:
Production-ready MCP server for intelligent JavaScript package management. Works with Claude, Windsurf, Cursor, VS Code, and any MCP-compatible AI editor.
375 lines (344 loc) • 12.2 kB
text/typescript
import pacote from 'pacote';
import { fetch, RequestInit, Response } from 'undici';
import { Package, DownloadStats, BundleSize, PackageSearchResult } from '../models/Package.js';
import { URLS } from '../constants.js';
/**
* Service for interacting with npm registry and related APIs including bundlephobia.
* Provides methods to search packages, retrieve metadata, download statistics, and bundle size information.
* Handles API communication with proper error handling and data transformation.
*
* @class RegistryService
* @example
* ```typescript
* const registryService = new RegistryService();
*
* // Search for packages
* const results = await registryService.search('react', 10);
*
* // Get package information
* const packageInfo = await registryService.getPackageInfo('lodash');
*
* // Get download statistics
* const stats = await registryService.getDownloadStats('lodash', 'last-month');
*
* // Get bundle size information
* const bundleSize = await registryService.getBundleSize('lodash', '4.17.21');
* ```
*/
export class RegistryService {
private readonly registryUrl: string;
private readonly bundlephobiaUrl: string;
private readonly npmApiUrl: string;
/**
* Creates a new RegistryService instance with configurable API endpoints.
*
* @param registryUrl - NPM registry URL (default: URLS.NPM_REGISTRY)
* @param bundlephobiaUrl - Bundlephobia API URL (default: URLS.BUNDLEPHOBIA_API)
* @param npmApiUrl - NPM API URL (default: URLS.NPM_API)
*
* @example
* ```typescript
* // Use default endpoints
* const registry = new RegistryService();
*
* // Use custom registry (e.g., private registry)
* const privateRegistry = new RegistryService(
* 'https://my-private-registry.com',
* URLS.BUNDLEPHOBIA_API,
* URLS.NPM_API
* );
* ```
*/
constructor(
registryUrl: string = URLS.NPM_REGISTRY,
bundlephobiaUrl: string = URLS.BUNDLEPHOBIA_API,
npmApiUrl: string = URLS.NPM_API
) {
this.registryUrl = registryUrl;
this.bundlephobiaUrl = bundlephobiaUrl;
this.npmApiUrl = npmApiUrl;
}
/**
* Searches for packages in the npm registry with quality scoring.
* Uses npm's search API with configurable quality, popularity, and maintenance weights.
*
* @param query - Search query string
* @param limit - Maximum number of results to return (default: 25)
* @param from - Pagination offset (default: 0)
* @returns Promise resolving to array of PackageSearchResult objects
*
* @example
* ```typescript
* // Basic search
* const results = await registryService.search('react');
*
* // Search with pagination
* const page2 = await registryService.search('react', 10, 10);
*
* // Process results
* results.forEach(pkg => {
* console.log(`${pkg.name} - Score: ${pkg.score.final}`);
* console.log(`Quality: ${pkg.score.detail.quality}`);
* console.log(`Popularity: ${pkg.score.detail.popularity}`);
* });
* ```
*/
async search(query: string, limit: number = 25, from: number = 0): Promise<PackageSearchResult[]> {
try {
const searchUrl = `${this.npmApiUrl}/search`;
const params = new URLSearchParams({
text: query,
size: limit.toString(),
from: from.toString(),
quality: '0.9',
popularity: '0.8',
maintenance: '0.7',
});
const response = await fetch(`${searchUrl}?${params}`);
if (!response.ok) {
throw new Error(`Search failed: ${response.status} ${response.statusText}`);
}
const data = await response.json() as any;
return data.objects?.map((item: any) => this.transformSearchResult(item)) || [];
} catch (error) {
throw new Error(`Failed to search packages: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Retrieves detailed package information from the npm registry.
* Uses pacote to fetch package manifest with comprehensive metadata.
*
* @param packageName - Name of the package to retrieve
* @param version - Specific version to fetch (defaults to latest)
* @returns Promise resolving to Package object with complete metadata
*
* @example
* ```typescript
* // Get latest version
* const lodash = await registryService.getPackageInfo('lodash');
* console.log(`Latest: ${lodash.version}`);
*
* // Get specific version
* const oldLodash = await registryService.getPackageInfo('lodash', '4.17.20');
*
* // Access package details
* console.log(`Description: ${lodash.description}`);
* console.log(`License: ${lodash.license}`);
* console.log(`Dependencies:`, lodash.dependencies);
* console.log(`Repository:`, lodash.repository?.url);
* ```
*/
async getPackageInfo(packageName: string, version?: string): Promise<Package> {
try {
const manifest = await pacote.manifest(`${packageName}${version ? `@${version}` : ''}`, {
registry: this.registryUrl,
});
return this.transformPackageManifest(manifest);
} catch (error) {
throw new Error(`Failed to get package info for ${packageName}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Retrieves download statistics for a package from npm's download API.
* Provides download counts for different time periods.
*
* @param packageName - Name of the package to get stats for
* @param period - Time period for statistics
* @returns Promise resolving to DownloadStats object
*
* @example
* ```typescript
* // Get monthly downloads
* const monthlyStats = await registryService.getDownloadStats('lodash', 'last-month');
* console.log(`Downloads: ${monthlyStats.downloads.toLocaleString()}`);
*
* // Get weekly downloads
* const weeklyStats = await registryService.getDownloadStats('react', 'last-week');
*
* // Compare popularity
* const [lodashStats, reactStats] = await Promise.all([
* registryService.getDownloadStats('lodash', 'last-month'),
* registryService.getDownloadStats('react', 'last-month')
* ]);
*
* console.log(`Lodash: ${lodashStats.downloads}`);
* console.log(`React: ${reactStats.downloads}`);
* ```
*/
async getDownloadStats(packageName: string, period: 'last-day' | 'last-week' | 'last-month' | 'last-year'): Promise<DownloadStats> {
try {
const encodedName = encodeURIComponent(packageName);
const response = await fetch(`${this.npmApiUrl}/downloads/point/${period}/${encodedName}`);
if (!response.ok) {
throw new Error(`Downloads API failed: ${response.status}`);
}
const data = await response.json() as any;
return {
downloads: data.downloads || 0,
period,
start: data.start || '',
end: data.end || '',
};
} catch (error) {
throw new Error(`Failed to get download stats: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Get package bundle size information
*/
async getBundleSize(packageName: string, version?: string): Promise<BundleSize> {
try {
const encodedName = encodeURIComponent(packageName);
const versionParam = version ? `@${version}` : '';
const response = await fetch(`${this.bundlephobiaUrl}/size?package=${encodedName}${versionParam}`);
if (!response.ok) {
throw new Error(`Bundlephobia API failed: ${response.status}`);
}
const data = await response.json() as any;
return {
size: data.size || 0,
gzip: data.gzip || 0,
dependency: data.dependencyCount || 0,
dependencyCount: data.dependencyCount || 0,
};
} catch (error) {
// Bundlephobia might not have data for all packages, so we provide defaults
return {
size: 0,
gzip: 0,
dependency: 0,
dependencyCount: 0,
};
}
}
/**
* Get package metadata from registry
*/
async getPackageMetadata(packageName: string): Promise<any> {
try {
const response = await fetch(`${this.registryUrl}/${encodeURIComponent(packageName)}`);
if (!response.ok) {
throw new Error(`Registry request failed: ${response.status}`);
}
return await response.json();
} catch (error) {
throw new Error(`Failed to get package metadata: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Get package versions
*/
async getPackageVersions(packageName: string): Promise<string[]> {
try {
const metadata = await this.getPackageMetadata(packageName);
return Object.keys(metadata.versions || {}).sort((a, b) => {
// Simple version sort - in production, use semver.sort
return b.localeCompare(a);
});
} catch (error) {
throw new Error(`Failed to get package versions: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Check if package exists
*/
async packageExists(packageName: string): Promise<boolean> {
try {
const response = await fetch(`${this.registryUrl}/${encodeURIComponent(packageName)}`, {
method: 'HEAD',
});
return response.ok;
} catch {
return false;
}
}
/**
* Transform search result from npm API format
*/
private transformSearchResult(item: any): PackageSearchResult {
const pkg = item.package;
return {
name: pkg.name,
version: pkg.version,
description: pkg.description,
keywords: pkg.keywords,
author: this.transformAuthor(pkg.author),
publishedAt: pkg.date,
score: {
final: item.score?.final || 0,
detail: {
quality: item.score?.detail?.quality || 0,
popularity: item.score?.detail?.popularity || 0,
maintenance: item.score?.detail?.maintenance || 0,
},
},
searchScore: item.searchScore,
};
}
/**
* Transform package manifest from pacote format
*/
private transformPackageManifest(manifest: any): Package {
return {
name: manifest.name,
version: manifest.version,
description: manifest.description,
keywords: manifest.keywords,
homepage: manifest.homepage,
repository: manifest.repository ? {
type: manifest.repository.type || 'git',
url: manifest.repository.url || manifest.repository,
directory: manifest.repository.directory,
} : undefined,
bugs: manifest.bugs ? {
url: manifest.bugs.url || manifest.bugs,
email: manifest.bugs.email,
} : undefined,
license: manifest.license,
author: this.transformAuthor(manifest.author),
maintainers: manifest.maintainers?.map((m: any) => this.transformAuthor(m)),
dependencies: manifest.dependencies,
devDependencies: manifest.devDependencies,
peerDependencies: manifest.peerDependencies,
engines: manifest.engines,
scripts: manifest.scripts,
publishedAt: manifest.time?.[manifest.version],
};
}
/**
* Transform author information
*/
private transformAuthor(author: any): any {
if (!author) return undefined;
if (typeof author === 'string') {
// Parse "Name <email> (url)" format
const match = author.match(/^([^<(]+?)(?:\s*<([^>]+)>)?(?:\s*\(([^)]+)\))?$/);
if (match) {
return {
name: match[1].trim(),
email: match[2]?.trim(),
url: match[3]?.trim(),
};
}
return { name: author };
}
return {
name: author.name,
email: author.email,
url: author.url,
};
}
/**
* Rate limit helper for API calls
*/
private async rateLimitedFetch(url: string, options?: RequestInit): Promise<Response> {
// Simple rate limiting - in production, use a proper rate limiter
const response = await fetch(url, options);
if (response.status === 429) {
// Rate limited, wait and retry
await new Promise(resolve => setTimeout(resolve, 1000));
return fetch(url, options);
}
return response;
}
}