UNPKG

@juspay/neurolink

Version:

Universal AI Development Platform with working MCP integration, multi-provider support, voice (TTS/STT/realtime), and professional CLI. 58+ external MCP servers discoverable, multimodal file processing, RAG pipelines. Build, test, and deploy AI applicatio

503 lines (502 loc) 15.8 kB
/** * MCP Server Capabilities - Resources and Prompts * * Extends MCP server functionality with resource and prompt handling * according to MCP specification. This module provides: * - Resource registration and management * - Prompt template registration and execution * - Resource subscription support * * @module mcp/serverCapabilities * @since 8.39.0 */ import { EventEmitter } from "events"; import { logger } from "../utils/logger.js"; import { ErrorFactory } from "../utils/errorHandling.js"; import { withTimeout } from "../utils/async/withTimeout.js"; /** * Server Capabilities Manager * * Manages resources and prompts for MCP servers. * * @example * ```typescript * const capabilities = new ServerCapabilitiesManager({ * resources: true, * prompts: true, * }); * * // Register a resource * capabilities.registerResource({ * uri: "file:///data/config.json", * name: "Configuration", * mimeType: "application/json", * reader: async (uri) => ({ * uri, * mimeType: "application/json", * text: JSON.stringify({ key: "value" }), * }), * }); * * // Register a prompt * capabilities.registerPrompt({ * name: "summarize", * description: "Summarize text content", * arguments: [{ name: "text", required: true }], * generator: async (args) => ({ * messages: [ * { role: "user", content: { type: "text", text: `Summarize: ${args.text}` } }, * ], * }), * }); * ``` */ export class ServerCapabilitiesManager extends EventEmitter { config; resources = new Map(); prompts = new Map(); subscriptions = new Map(); resourceTemplates = new Map(); constructor(config = {}) { super(); this.config = { resources: config.resources ?? true, prompts: config.prompts ?? true, resourceSubscriptions: config.resourceSubscriptions ?? true, }; } // ======================================== // RESOURCE MANAGEMENT // ======================================== /** * Register a resource */ registerResource(resource) { if (!this.config.resources) { throw ErrorFactory.invalidConfiguration("resources", "Resource support is not enabled"); } this.validateResourceUri(resource.uri); this.resources.set(resource.uri, resource); this.emit("resourceRegistered", { uri: resource.uri, name: resource.name, timestamp: new Date(), }); logger.debug(`[ServerCapabilities] Registered resource: ${resource.uri}`); return this; } /** * Register a resource template (with URI pattern) */ registerResourceTemplate(pattern, template) { if (!this.config.resources) { throw ErrorFactory.invalidConfiguration("resources", "Resource support is not enabled"); } this.resourceTemplates.set(pattern, { ...template, uri: template.uriPattern, }); this.emit("resourceTemplateRegistered", { pattern, timestamp: new Date(), }); return this; } /** * Unregister a resource */ unregisterResource(uri) { const removed = this.resources.delete(uri); if (removed) { // Clear subscriptions this.subscriptions.delete(uri); this.emit("resourceUnregistered", { uri, timestamp: new Date(), }); } return removed; } /** * List all resources */ listResources() { return Array.from(this.resources.values()).map((r) => ({ uri: r.uri, name: r.name, description: r.description, mimeType: r.mimeType, size: r.size, dynamic: r.dynamic, annotations: r.annotations, })); } /** * Read a resource */ async readResource(uri, context) { // Check direct resources let resource = this.resources.get(uri); // Check templates if not found if (!resource) { resource = this.findResourceTemplate(uri); } if (!resource) { throw ErrorFactory.invalidConfiguration("resource", `Resource not found: ${uri}`); } const startTime = Date.now(); const resourceTimeoutMs = 30_000; try { const content = await withTimeout(resource.reader(uri, context), resourceTimeoutMs, `Resource read timed out after ${resourceTimeoutMs}ms for URI: ${uri}`); const duration = Date.now() - startTime; this.emit("resourceRead", { uri, duration, success: true, timestamp: new Date(), }); return content; } catch (error) { const duration = Date.now() - startTime; this.emit("resourceRead", { uri, duration, success: false, error: error instanceof Error ? error.message : String(error), timestamp: new Date(), }); throw error; } } /** * Subscribe to resource changes */ subscribeToResource(uri, callback) { if (!this.config.resourceSubscriptions) { throw ErrorFactory.invalidConfiguration("resourceSubscriptions", "Resource subscriptions are not enabled"); } if (!this.subscriptions.has(uri)) { this.subscriptions.set(uri, new Set()); } const subs = this.subscriptions.get(uri); if (subs) { subs.add(callback); } this.emit("resourceSubscribed", { uri, timestamp: new Date(), }); // Return unsubscribe function return () => { const subs = this.subscriptions.get(uri); if (subs) { subs.delete(callback); if (subs.size === 0) { this.subscriptions.delete(uri); } } this.emit("resourceUnsubscribed", { uri, timestamp: new Date(), }); }; } /** * Notify subscribers of resource change */ async notifyResourceChanged(uri) { const subscribers = this.subscriptions.get(uri); if (!subscribers || subscribers.size === 0) { return; } try { const content = await this.readResource(uri); for (const callback of subscribers) { try { const result = callback(uri, content); // Handle async callbacks that return promises if (result && typeof result.catch === "function") { result.catch((error) => { logger.error(`[ServerCapabilities] Async error notifying subscriber for ${uri}:`, error); }); } } catch (error) { logger.error(`[ServerCapabilities] Error notifying subscriber for ${uri}:`, error); } } this.emit("resourceChanged", { uri, subscriberCount: subscribers.size, timestamp: new Date(), }); } catch (error) { logger.error(`[ServerCapabilities] Error reading resource for notification: ${uri}`, error); } } /** * Get resource by URI */ getResource(uri) { return this.resources.get(uri) ?? this.findResourceTemplate(uri); } /** * Validate resource URI */ validateResourceUri(uri) { try { new URL(uri); } catch { // Allow non-URL URIs but warn logger.warn(`[ServerCapabilities] Resource URI is not a valid URL: ${uri}`); } } /** * Find matching resource template */ findResourceTemplate(uri) { for (const [pattern, template] of this.resourceTemplates) { if (this.matchesPattern(uri, pattern)) { return { ...template, uri, }; } } return undefined; } /** * Check if URI matches a pattern */ matchesPattern(uri, pattern) { // Guard against excessively long patterns that could cause ReDoS if (pattern.length > 200) { return false; } // Escape regex metacharacters, then replace glob wildcards const regexPattern = pattern .replace(/[.+^${}()|[\]\\]/g, "\\$&") .replace(/\*/g, ".*") .replace(/\?/g, ".") // Restore URI template placeholders: \{...\} -> [^/]+ .replace(/\\\{[^}]*\\\}/g, "[^/]+"); try { return new RegExp(`^${regexPattern}$`).test(uri); } catch { // Invalid regex pattern — treat as non-match return false; } } // ======================================== // PROMPT MANAGEMENT // ======================================== /** * Register a prompt */ registerPrompt(prompt) { if (!this.config.prompts) { throw ErrorFactory.invalidConfiguration("prompts", "Prompt support is not enabled"); } this.validatePromptName(prompt.name); this.prompts.set(prompt.name, prompt); this.emit("promptRegistered", { name: prompt.name, timestamp: new Date(), }); logger.debug(`[ServerCapabilities] Registered prompt: ${prompt.name}`); return this; } /** * Unregister a prompt */ unregisterPrompt(name) { const removed = this.prompts.delete(name); if (removed) { this.emit("promptUnregistered", { name, timestamp: new Date(), }); } return removed; } /** * List all prompts */ listPrompts() { return Array.from(this.prompts.values()).map((p) => ({ name: p.name, description: p.description, arguments: p.arguments, })); } /** * Get a prompt */ async getPrompt(name, args = {}, context) { const prompt = this.prompts.get(name); if (!prompt) { throw ErrorFactory.invalidConfiguration("prompt", `Prompt not found: ${name}`); } // Validate required arguments for (const arg of prompt.arguments ?? []) { if (arg.required && args[arg.name] === undefined) { throw ErrorFactory.invalidConfiguration("promptArgument", `Missing required argument: ${arg.name}`); } } const startTime = Date.now(); const promptTimeoutMs = 30_000; try { const result = await withTimeout(prompt.generator(args, context), promptTimeoutMs, `Prompt generation timed out after ${promptTimeoutMs}ms for prompt: ${name}`); const duration = Date.now() - startTime; this.emit("promptGenerated", { name, duration, success: true, messageCount: result.messages.length, timestamp: new Date(), }); return result; } catch (error) { const duration = Date.now() - startTime; this.emit("promptGenerated", { name, duration, success: false, error: error instanceof Error ? error.message : String(error), timestamp: new Date(), }); throw error; } } /** * Get prompt by name */ getPromptDefinition(name) { return this.prompts.get(name); } /** * Validate prompt name */ validatePromptName(name) { if (!name || typeof name !== "string") { throw ErrorFactory.invalidConfiguration("promptName", "Prompt name is required and must be a string"); } if (!/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(name)) { throw ErrorFactory.invalidConfiguration("promptName", "Prompt name must start with a letter or underscore and contain only alphanumeric characters, underscores, and hyphens"); } if (this.prompts.has(name)) { throw ErrorFactory.invalidConfiguration("promptName", `Prompt '${name}' is already registered`); } } // ======================================== // UTILITY METHODS // ======================================== /** * Get capabilities object for MCP protocol */ getCapabilities() { const capabilities = {}; if (this.config.resources) { capabilities.resources = { subscribe: this.config.resourceSubscriptions, listChanged: true, }; } if (this.config.prompts) { capabilities.prompts = { listChanged: true, }; } return capabilities; } /** * Get statistics */ getStatistics() { let subscriptionCount = 0; for (const subs of this.subscriptions.values()) { subscriptionCount += subs.size; } return { resourceCount: this.resources.size, templateCount: this.resourceTemplates.size, promptCount: this.prompts.size, subscriptionCount, }; } /** * Clear all resources and prompts */ clear() { this.resources.clear(); this.resourceTemplates.clear(); this.prompts.clear(); this.subscriptions.clear(); this.emit("cleared", { timestamp: new Date() }); } } /** * Create a simple text resource */ export function createTextResource(uri, name, content, options) { return { uri, name, description: options?.description, mimeType: "text/plain", dynamic: options?.dynamic ?? typeof content === "function", reader: async (requestUri) => ({ uri: requestUri, mimeType: "text/plain", text: typeof content === "function" ? await content() : content, }), }; } /** * Create a JSON resource */ export function createJsonResource(uri, name, content, options) { return { uri, name, description: options?.description, mimeType: "application/json", dynamic: options?.dynamic ?? typeof content === "function", reader: async (requestUri) => ({ uri: requestUri, mimeType: "application/json", text: JSON.stringify(typeof content === "function" ? await content() : content, null, 2), }), }; } /** * Create a simple prompt template */ export function createPrompt(name, template, options) { return { name, description: options?.description, arguments: options?.arguments, generator: async (args) => { // Simple template substitution let text = template; for (const [key, value] of Object.entries(args)) { const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); text = text.replace(new RegExp(`\\{${escapedKey}\\}`, "g"), () => String(value)); } return { messages: [ { role: "user", content: { type: "text", text }, }, ], }; }, }; }