/** * @claude-flow/mcp - Resource Registry * * MCP 2025-11-25 compliant resource management * Supports: list, read, subscribe, templates, pagination */ import { EventEmitter } from 'events'; import type { MCPResource, ResourceContent, ResourceTemplate, ResourceListResult, ResourceReadResult, ILogger, ContentAnnotations, } from './types.js'; export type ResourceHandler = (uri: string) => Promise; export type SubscriptionCallback = (uri: string, content: ResourceContent[]) => void; export interface ResourceRegistryOptions { enableSubscriptions?: boolean; maxSubscriptionsPerResource?: number; cacheEnabled?: boolean; cacheTTL?: number; maxCacheSize?: number; // SECURITY: Prevent unbounded cache growth } interface CachedResource { content: ResourceContent[]; cachedAt: number; ttl: number; } interface Subscription { id: string; uri: string; callback: SubscriptionCallback; createdAt: Date; } export class ResourceRegistry extends EventEmitter { private resources: Map = new Map(); private templates: Map = new Map(); private handlers: Map = new Map(); private subscriptions: Map = new Map(); private cache: Map = new Map(); private subscriptionCounter = 0; private readonly options: Required; constructor( private readonly logger: ILogger, options: ResourceRegistryOptions = {} ) { super(); this.options = { enableSubscriptions: options.enableSubscriptions ?? true, maxSubscriptionsPerResource: options.maxSubscriptionsPerResource ?? 100, cacheEnabled: options.cacheEnabled ?? true, cacheTTL: options.cacheTTL ?? 60000, // 1 minute default maxCacheSize: options.maxCacheSize ?? 1000, // SECURITY: Default max 1000 entries }; } /** * Register a static resource */ registerResource(resource: MCPResource, handler: ResourceHandler): boolean { if (this.resources.has(resource.uri)) { this.logger.warn('Resource already registered', { uri: resource.uri }); return false; } this.resources.set(resource.uri, resource); this.handlers.set(resource.uri, handler); this.logger.debug('Resource registered', { uri: resource.uri, name: resource.name }); this.emit('resource:registered', { uri: resource.uri }); this.emitListChanged(); return true; } /** * Register a resource template (dynamic URIs) */ registerTemplate(template: ResourceTemplate, handler: ResourceHandler): boolean { if (this.templates.has(template.uriTemplate)) { this.logger.warn('Template already registered', { template: template.uriTemplate }); return false; } this.templates.set(template.uriTemplate, template); this.handlers.set(template.uriTemplate, handler); this.logger.debug('Resource template registered', { template: template.uriTemplate }); this.emit('template:registered', { template: template.uriTemplate }); return true; } /** * Unregister a resource */ unregisterResource(uri: string): boolean { if (!this.resources.has(uri)) { return false; } this.resources.delete(uri); this.handlers.delete(uri); this.cache.delete(uri); // Cancel subscriptions for this resource const subs = this.subscriptions.get(uri) || []; for (const sub of subs) { this.emit('subscription:cancelled', { subscriptionId: sub.id, uri }); } this.subscriptions.delete(uri); this.logger.debug('Resource unregistered', { uri }); this.emit('resource:unregistered', { uri }); this.emitListChanged(); return true; } /** * List resources with pagination */ list(cursor?: string, pageSize: number = 50): ResourceListResult { const allResources = Array.from(this.resources.values()); let startIndex = 0; if (cursor) { const decoded = this.decodeCursor(cursor); startIndex = decoded.offset; } const endIndex = Math.min(startIndex + pageSize, allResources.length); const resources = allResources.slice(startIndex, endIndex); const result: ResourceListResult = { resources }; if (endIndex < allResources.length) { result.nextCursor = this.encodeCursor({ offset: endIndex }); } return result; } /** * Read resource content */ async read(uri: string): Promise { // Check cache first if (this.options.cacheEnabled) { const cached = this.cache.get(uri); if (cached && Date.now() - cached.cachedAt < cached.ttl) { this.logger.debug('Resource cache hit', { uri }); return { contents: cached.content }; } } // Find handler (exact match or template match) let handler = this.handlers.get(uri); if (!handler) { handler = this.findTemplateHandler(uri); } if (!handler) { throw new Error(`Resource not found: ${uri}`); } const contents = await handler(uri); // Cache the result with size limit (LRU eviction) if (this.options.cacheEnabled) { // SECURITY: Enforce max cache size to prevent memory exhaustion if (this.cache.size >= this.options.maxCacheSize) { // Remove oldest entry (first entry in Map iteration order) const oldestKey = this.cache.keys().next().value; if (oldestKey) { this.cache.delete(oldestKey); this.logger.debug('Cache evicted oldest entry', { uri: oldestKey }); } } this.cache.set(uri, { content: contents, cachedAt: Date.now(), ttl: this.options.cacheTTL, }); } this.emit('resource:read', { uri, contentCount: contents.length }); return { contents }; } /** * Subscribe to resource updates */ subscribe(uri: string, callback: SubscriptionCallback): string { if (!this.options.enableSubscriptions) { throw new Error('Subscriptions are disabled'); } const existingSubs = this.subscriptions.get(uri) || []; if (existingSubs.length >= this.options.maxSubscriptionsPerResource) { throw new Error(`Maximum subscriptions reached for resource: ${uri}`); } const subscriptionId = `sub-${++this.subscriptionCounter}-${Date.now()}`; const subscription: Subscription = { id: subscriptionId, uri, callback, createdAt: new Date(), }; existingSubs.push(subscription); this.subscriptions.set(uri, existingSubs); this.logger.debug('Subscription created', { subscriptionId, uri }); this.emit('subscription:created', { subscriptionId, uri }); return subscriptionId; } /** * Unsubscribe from resource updates */ unsubscribe(subscriptionId: string): boolean { for (const [uri, subs] of this.subscriptions) { const index = subs.findIndex((s) => s.id === subscriptionId); if (index !== -1) { subs.splice(index, 1); if (subs.length === 0) { this.subscriptions.delete(uri); } this.logger.debug('Subscription removed', { subscriptionId, uri }); this.emit('subscription:removed', { subscriptionId, uri }); return true; } } return false; } /** * Notify subscribers of resource update */ async notifyUpdate(uri: string): Promise { const subs = this.subscriptions.get(uri); if (!subs || subs.length === 0) { return; } // Invalidate cache this.cache.delete(uri); // Read fresh content const { contents } = await this.read(uri); // Notify all subscribers for (const sub of subs) { try { sub.callback(uri, contents); } catch (error) { this.logger.error('Subscription callback error', { subscriptionId: sub.id, error }); } } this.emit('resource:updated', { uri, subscriberCount: subs.length }); } /** * Get resource by URI */ getResource(uri: string): MCPResource | undefined { return this.resources.get(uri); } /** * Check if resource exists */ hasResource(uri: string): boolean { return this.resources.has(uri) || this.matchesTemplate(uri); } /** * Get resource count */ getResourceCount(): number { return this.resources.size; } /** * Get all templates */ getTemplates(): ResourceTemplate[] { return Array.from(this.templates.values()); } /** * Get subscription count for a resource */ getSubscriptionCount(uri: string): number { return this.subscriptions.get(uri)?.length || 0; } /** * Get stats */ getStats(): { totalResources: number; totalTemplates: number; totalSubscriptions: number; cacheSize: number; } { let totalSubscriptions = 0; for (const subs of this.subscriptions.values()) { totalSubscriptions += subs.length; } return { totalResources: this.resources.size, totalTemplates: this.templates.size, totalSubscriptions, cacheSize: this.cache.size, }; } /** * Clear cache */ clearCache(): void { this.cache.clear(); this.logger.debug('Resource cache cleared'); } /** * Find handler for template URI */ private findTemplateHandler(uri: string): ResourceHandler | undefined { for (const [template, handler] of this.handlers) { if (this.matchesTemplate(uri, template)) { return handler; } } return undefined; } /** * Escape regex metacharacters to prevent ReDoS attacks * SECURITY: Critical for preventing regex denial of service */ private escapeRegex(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } /** * Check if URI matches any template * SECURITY: Uses escaped regex to prevent ReDoS */ private matchesTemplate(uri: string, template?: string): boolean { if (template) { // SECURITY: Escape regex metacharacters before converting template // First extract placeholders, escape the rest, then add placeholder pattern const escaped = this.escapeRegex(template); // Replace escaped placeholder braces with the pattern const pattern = escaped.replace(/\\\{[^}]+\\\}/g, '[^/]+'); try { const regex = new RegExp('^' + pattern + '$'); return regex.test(uri); } catch { // Invalid regex pattern - return false safely return false; } } for (const t of this.templates.keys()) { const escaped = this.escapeRegex(t); const pattern = escaped.replace(/\\\{[^}]+\\\}/g, '[^/]+'); try { const regex = new RegExp('^' + pattern + '$'); if (regex.test(uri)) { return true; } } catch { // Skip invalid patterns continue; } } return false; } /** * Encode cursor for pagination */ private encodeCursor(data: { offset: number }): string { return Buffer.from(JSON.stringify(data)).toString('base64'); } /** * Decode cursor for pagination */ private decodeCursor(cursor: string): { offset: number } { try { return JSON.parse(Buffer.from(cursor, 'base64').toString('utf-8')); } catch { return { offset: 0 }; } } /** * Emit listChanged notification */ private emitListChanged(): void { this.emit('resources:listChanged'); } } export function createResourceRegistry( logger: ILogger, options?: ResourceRegistryOptions ): ResourceRegistry { return new ResourceRegistry(logger, options); } /** * Helper to create a static text resource */ export function createTextResource( uri: string, name: string, text: string, options?: { description?: string; mimeType?: string; annotations?: ContentAnnotations; } ): { resource: MCPResource; handler: ResourceHandler } { const resource: MCPResource = { uri, name, description: options?.description, mimeType: options?.mimeType || 'text/plain', annotations: options?.annotations, }; const handler: ResourceHandler = async () => [ { uri, mimeType: options?.mimeType || 'text/plain', text, }, ]; return { resource, handler }; } /** * Helper to create a file resource * SECURITY: Validates path to prevent path traversal attacks */ export function createFileResource( uri: string, name: string, filePath: string, options?: { description?: string; mimeType?: string; allowedBasePaths?: string[]; // Security: restrict to these base paths } ): { resource: MCPResource; handler: ResourceHandler } { const resource: MCPResource = { uri, name, description: options?.description, mimeType: options?.mimeType || 'application/octet-stream', }; const handler: ResourceHandler = async () => { const fs = await import('fs/promises'); const path = await import('path'); // SECURITY: Normalize and validate the path const normalizedPath = path.normalize(filePath); // Prevent path traversal if (normalizedPath.includes('..') || normalizedPath.includes('\0')) { throw new Error('Invalid file path: path traversal detected'); } // Prevent access to sensitive system paths const blockedPaths = ['/etc/', '/proc/', '/sys/', '/dev/', '/root/', '/var/log/']; const lowerPath = normalizedPath.toLowerCase(); for (const blocked of blockedPaths) { if (lowerPath.startsWith(blocked) || lowerPath.includes('/.')) { throw new Error('Access to system paths is not allowed'); } } // If allowedBasePaths specified, validate against them if (options?.allowedBasePaths && options.allowedBasePaths.length > 0) { const resolvedPath = path.resolve(normalizedPath); const isAllowed = options.allowedBasePaths.some((basePath) => { const resolvedBase = path.resolve(basePath); return resolvedPath.startsWith(resolvedBase); }); if (!isAllowed) { throw new Error('File path is outside allowed directories'); } } const content = await fs.readFile(normalizedPath); return [ { uri, mimeType: options?.mimeType || 'application/octet-stream', blob: content.toString('base64'), }, ]; }; return { resource, handler }; }