UNPKG

quality-mcp

Version:

An MCP server that analyzes to your codebase, with plugin support for DCD and Simian. 🏍️ "The only Zen you find on the tops of mountains is the Zen you bring up there."

581 lines (504 loc) 15.9 kB
/** * Plugin Manager * Handles registration, lifecycle, and communication between MCP server and analysis plugins */ import { EventEmitter } from 'events'; import { createLogger } from '../utils/logger.js'; import { CallToolRequestSchema, ReadResourceRequestSchema, GetPromptRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ListPromptsRequestSchema, } from '@modelcontextprotocol/sdk/types.js'; const logger = createLogger('plugin-manager'); /** * Dependency injection function for plugin manager * @returns {Object} Dependencies object */ export function getDeps() { return { logger, }; } /** * Abstract base class for analysis plugins */ export class AnalysisPlugin { constructor(name, config = {}) { this.name = name; this.config = config; this.enabled = true; } /** * Initialize the plugin * @returns {Promise<void>} */ async initialize() { throw new Error('initialize() must be implemented by plugin'); } /** * Get the tools this plugin provides * @returns {Array<Object>} Array of MCP tool definitions */ getTools() { return []; } /** * Get the resources this plugin provides * @returns {Array<Object>} Array of MCP resource definitions */ getResources() { return []; } /** * Get the prompts this plugin provides * @returns {Array<Object>} Array of MCP prompt definitions */ getPrompts() { return []; } /** * Execute a tool * @param {string} toolName - Name of the tool to execute * @param {Object} _params - Tool parameters * @returns {Promise<Object>} Tool execution result */ async executeTool(toolName, _params) { throw new Error(`Tool ${toolName} not implemented by plugin ${this.name}`); } /** * Get a resource * @param {string} resourceUri - URI of the resource to get * @returns {Promise<Object>} Resource data */ async getResource(resourceUri) { throw new Error(`Resource ${resourceUri} not implemented by plugin ${this.name}`); } /** * Get a prompt * @param {string} promptName - Name of the prompt to get * @param {Object} _params - Prompt parameters * @returns {Promise<Object>} Prompt data */ async getPrompt(promptName, _params) { throw new Error(`Prompt ${promptName} not implemented by plugin ${this.name}`); } /** * Shutdown the plugin * @returns {Promise<void>} */ async shutdown() { logger.info(`Shutting down plugin: ${this.name}`); } } /** * Plugin Manager - orchestrates multiple analysis plugins */ export class PluginManager extends EventEmitter { constructor(server, _getDeps = getDeps) { super(); this.server = server; this.plugins = new Map(); this.toolRegistry = new Map(); this.resourceRegistry = new Map(); this.promptRegistry = new Map(); this.setupServerHandlers(); } /** * Set up MCP server request handlers */ setupServerHandlers(_getDeps = getDeps) { const { logger } = _getDeps(); try { // Register the get_plugin_status tool this.toolRegistry.set('get_plugin_status', { plugin: this, definition: { name: 'get_plugin_status', description: 'Check the status and availability of all plugins and their tools', inputSchema: { type: 'object', properties: { plugin: { type: 'string', description: 'Specific plugin to check (optional - if not provided, checks all plugins)', enum: ['dcd', 'simian', 'all'], default: 'all', }, }, required: [], }, }, }); logger.debug('Plugin manager handlers setup completed'); } catch (error) { logger.error('Failed to setup plugin manager handlers:', error); throw error; } // Handle tool execution requests this.server.setRequestHandler(CallToolRequestSchema, async request => { try { const { name, arguments: args = {} } = request.params; if (!this.toolRegistry.has(name)) { throw new Error(`Unknown tool: ${name}`); } const { plugin } = this.toolRegistry.get(name); // Special handling for get_plugin_status tool if (name === 'get_plugin_status') { const result = await this.getPluginStatus(); // Return result in proper MCP format without double serialization return this.formatToolResponse(result, name); } const result = await plugin.executeTool(name, args); // Return result in proper MCP format without double serialization return this.formatToolResponse(result, name); } catch (error) { logger.error('Tool execution error:', error); throw error; } }); // Handle resource requests this.server.setRequestHandler(ReadResourceRequestSchema, async request => { try { const { uri } = request.params; if (!this.resourceRegistry.has(uri)) { throw new Error(`Unknown resource: ${uri}`); } const { plugin } = this.resourceRegistry.get(uri); const result = await plugin.getResource(uri); // Return response in proper MCP format using the same pattern as tools return { contents: [ { uri, mimeType: result.mimeType || 'application/json', text: this.formatResourceResponse(result.data, uri), }, ], }; } catch (error) { logger.error('Resource read error:', error); throw error; } }); // Handle prompt requests this.server.setRequestHandler(GetPromptRequestSchema, async request => { try { const { name, arguments: args = {} } = request.params; if (!this.promptRegistry.has(name)) { throw new Error(`Unknown prompt: ${name}`); } const { plugin } = this.promptRegistry.get(name); const result = await plugin.getPrompt(name, args); return result; } catch (error) { logger.error('Prompt get error:', error); throw error; } }); // Handle tools list requests this.server.setRequestHandler(ListToolsRequestSchema, async () => { try { logger.debug('Handling ListToolsRequest'); const tools = []; for (const { definition } of this.toolRegistry.values()) { tools.push(definition); } logger.debug(`Returning ${tools.length} tools`); return { tools }; } catch (error) { logger.error('Error in ListToolsRequest handler:', error); throw error; } }); // Handle resources list requests this.server.setRequestHandler(ListResourcesRequestSchema, async () => { try { logger.debug('Handling ListResourcesRequest'); const resources = []; for (const { definition } of this.resourceRegistry.values()) { resources.push(definition); } logger.debug(`Returning ${resources.length} resources`); return { resources }; } catch (error) { logger.error('Error in ListResourcesRequest handler:', error); throw error; } }); // Handle resource templates list requests (none defined yet) this.server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => { try { logger.debug('Handling ListResourceTemplatesRequest'); return { resourceTemplates: [] }; } catch (error) { logger.error('Error in ListResourceTemplatesRequest handler:', error); throw error; } }); // Handle prompts list requests this.server.setRequestHandler(ListPromptsRequestSchema, async () => { try { logger.debug('Handling ListPromptsRequest'); const prompts = []; for (const { definition } of this.promptRegistry.values()) { prompts.push(definition); } logger.debug(`Returning ${prompts.length} prompts`); return { prompts }; } catch (error) { logger.error('Error in ListPromptsRequest handler:', error); throw error; } }); } /** * Format tool response in proper MCP format without double serialization * @param {any} result - Tool execution result * @param {string} toolName - Name of the tool for logging * @returns {Object} Properly formatted MCP response */ formatToolResponse(result, toolName, _getDeps = getDeps) { const { logger } = _getDeps(); // Handle different result types appropriately let responseText; if (typeof result === 'string') { // If result is already a string, use it directly responseText = result; } else if (result === null || result === undefined) { // Handle null/undefined gracefully responseText = '{}'; } else { // For objects, arrays, etc., serialize to JSON try { responseText = JSON.stringify(result, null, 2); } catch (jsonError) { logger.warn(`Failed to stringify tool result for ${toolName}, using fallback:`, jsonError); responseText = JSON.stringify({ error: 'Failed to serialize result', originalResult: String(result), toolName, }); } } // Return in proper MCP format return { content: [ { type: 'text', text: responseText, }, ], }; } /** * Format resource response in proper MCP format without double serialization * @param {any} data - Resource data * @param {string} uri - Resource URI for logging * @returns {string} Properly formatted response text */ formatResourceResponse(data, uri, _getDeps = getDeps) { const { logger } = _getDeps(); // Handle different data types appropriately if (typeof data === 'string') { // If data is already a string, use it directly return data; } else if (data === null || data === undefined) { // Handle null/undefined gracefully return '{}'; } else { // For objects, arrays, etc., serialize to JSON try { return JSON.stringify(data, null, 2); } catch (jsonError) { logger.warn(`Failed to stringify resource data for ${uri}, using fallback:`, jsonError); return JSON.stringify({ error: 'Failed to serialize resource data', originalData: String(data), uri, }); } } } /** * Register a plugin * @param {string} name - Plugin name * @param {AnalysisPlugin} plugin - Plugin instance * @returns {Promise<void>} */ async register(name, plugin) { logger.info(`Registering plugin: ${name}`); try { // Initialize the plugin await plugin.initialize(); // Store plugin this.plugins.set(name, plugin); // Register plugin's capabilities this.registerPluginTools(name, plugin); this.registerPluginResources(name, plugin); this.registerPluginPrompts(name, plugin); logger.info(`Plugin registered successfully: ${name}`); this.emit('pluginRegistered', name, plugin); } catch (error) { logger.error(`Failed to register plugin ${name}:`, error); this.emit('error', name, error); throw error; } } /** * Register tools from a plugin */ registerPluginTools(pluginName, plugin) { const tools = plugin.getTools(); for (const tool of tools) { const toolName = tool.name; if (this.toolRegistry.has(toolName)) { logger.warn(`Tool ${toolName} already registered, skipping`); continue; } this.toolRegistry.set(toolName, { plugin, definition: tool, }); logger.debug(`Registered tool: ${toolName} from plugin: ${pluginName}`); } } /** * Register resources from a plugin */ registerPluginResources(pluginName, plugin) { const resources = plugin.getResources(); for (const resource of resources) { const resourceUri = resource.uri; if (this.resourceRegistry.has(resourceUri)) { logger.warn(`Resource ${resourceUri} already registered, skipping`); continue; } this.resourceRegistry.set(resourceUri, { plugin, definition: resource, }); logger.debug(`Registered resource: ${resourceUri} from plugin: ${pluginName}`); } } /** * Register prompts from a plugin */ registerPluginPrompts(pluginName, plugin) { const prompts = plugin.getPrompts(); for (const prompt of prompts) { const promptName = prompt.name; if (this.promptRegistry.has(promptName)) { logger.warn(`Prompt ${promptName} already registered, skipping`); continue; } this.promptRegistry.set(promptName, { plugin, definition: prompt, }); logger.debug(`Registered prompt: ${promptName} from plugin: ${pluginName}`); } } /** * Get a plugin by name * @param {string} name - Plugin name * @returns {AnalysisPlugin|null} */ getPlugin(name) { return this.plugins.get(name) || null; } /** * Get all registered plugins * @returns {Map<string, AnalysisPlugin>} */ getPlugins() { return new Map(this.plugins); } /** * Check if a plugin is registered * @param {string} name - Plugin name * @returns {boolean} */ hasPlugin(name) { return this.plugins.has(name); } /** * Shutdown all plugins * @returns {Promise<void>} */ async shutdown(_getDeps = getDeps) { const { logger } = _getDeps(); logger.info('Shutting down all plugins...'); const shutdownPromises = Array.from(this.plugins.values()).map(async plugin => { try { await plugin.shutdown(); } catch (error) { logger.error(`Error shutting down plugin ${plugin.name}:`, error); } }); await Promise.all(shutdownPromises); // Clear registries this.plugins.clear(); this.toolRegistry.clear(); this.resourceRegistry.clear(); this.promptRegistry.clear(); logger.info('All plugins shutdown complete'); } /** * Get comprehensive status of all plugins * @returns {Object} Status information for all plugins */ async getPluginStatus(_getDeps = getDeps) { const { logger } = _getDeps(); logger.debug('Getting plugin status'); const available = []; const unavailable = []; let totalTools = 0; for (const [pluginName, plugin] of this.plugins) { try { const status = await plugin.getPluginStatus(); if (status.tools && status.tools.length > 0) { available.push(status); totalTools += status.tools.length; } else { unavailable.push(status); } } catch (error) { logger.warn(`Failed to get status for plugin ${pluginName}:`, error); unavailable.push({ plugin: pluginName, name: pluginName, message: `Failed to get status: ${error.message}`, }); } } const recommendations = []; if (unavailable.length > 0) { for (const plugin of unavailable) { if (plugin.installation) { if (plugin.plugin === 'dcd') { recommendations.push(`Install DCD: ${plugin.installation.command}`); } else if (plugin.plugin === 'simian') { recommendations.push( `Install Simian: ${plugin.installation.description} (requires commercial license)` ); } } } } return { available, unavailable, summary: { totalPlugins: this.plugins.size, availablePlugins: available.length, totalTools, recommendations, }, }; } }