// Copyright 2025 Chris Bunting
// Brief: Plugin architecture for extensibility in MCP Code Analysis & Quality Server
// Scope: Dynamic plugin loading and management system
import {
PluginInterface,
MCPRequest,
MCPResponse,
LoggerInterface,
ConfigInterface
} from '@mcp-code-analysis/shared-types';
export interface PluginMetadata {
name: string;
version: string;
description: string;
author: string;
license: string;
homepage?: string;
repository?: string;
keywords: string[];
dependencies: Record<string, string>;
devDependencies: Record<string, string>;
engines: {
node: string;
[key: string]: string;
};
}
export interface PluginContext {
logger: LoggerInterface;
config: ConfigInterface;
registerTool(tool: ToolDefinition): void;
unregisterTool(toolName: string): void;
getTool(toolName: string): ToolDefinition | undefined;
emit(event: string, data: any): void;
on(event: string, handler: (data: any) => void): void;
off(event: string, handler: (data: any) => void): void;
}
export interface ToolDefinition {
name: string;
description: string;
inputSchema: any;
handler: (params: any) => Promise<any>;
category?: string;
tags?: string[];
}
export interface PluginLoader {
loadPlugin(pluginPath: string): Promise<LoadedPlugin>;
unloadPlugin(pluginName: string): Promise<void>;
reloadPlugin(pluginName: string): Promise<void>;
getLoadedPlugins(): LoadedPlugin[];
getPlugin(pluginName: string): LoadedPlugin | undefined;
}
export interface LoadedPlugin {
metadata: PluginMetadata;
instance: PluginInterface;
context: PluginContext;
state: 'loaded' | 'active' | 'inactive' | 'error';
loadTime: Date;
lastActive?: Date;
error?: Error;
}
export class PluginManager implements PluginLoader {
private plugins: Map<string, LoadedPlugin> = new Map();
private tools: Map<string, ToolDefinition> = new Map();
private eventHandlers: Map<string, ((data: any) => void)[]> = new Map();
private logger: LoggerInterface;
private config: ConfigInterface;
constructor(logger: LoggerInterface, config: ConfigInterface) {
this.logger = logger;
this.config = config;
}
async loadPlugin(pluginPath: string): Promise<LoadedPlugin> {
try {
// Dynamic import of the plugin
const pluginModule = await import(pluginPath);
const PluginClass = pluginModule.default || pluginModule.Plugin;
if (!PluginClass) {
throw new Error(`Plugin module at ${pluginPath} does not export a default class or Plugin class`);
}
// Create plugin instance
const instance = new PluginClass();
// Validate plugin interface
if (!this.validatePluginInterface(instance)) {
throw new Error(`Plugin at ${pluginPath} does not implement the PluginInterface`);
}
// Extract metadata
const metadata = this.extractMetadata(pluginModule, instance);
// Create plugin context
const context = this.createPluginContext(metadata.name);
// Initialize plugin
await instance.initialize(this.config.get(`plugins.${metadata.name}`, {}));
// Store loaded plugin
const loadedPlugin: LoadedPlugin = {
metadata,
instance,
context,
state: 'loaded',
loadTime: new Date()
};
this.plugins.set(metadata.name, loadedPlugin);
// Activate plugin
await this.activatePlugin(metadata.name);
this.logger.info(`Loaded plugin: ${metadata.name} v${metadata.version}`);
return loadedPlugin;
} catch (error) {
this.logger.error(`Failed to load plugin from ${pluginPath}:`, error);
throw error;
}
}
async unloadPlugin(pluginName: string): Promise<void> {
const plugin = this.plugins.get(pluginName);
if (!plugin) {
throw new Error(`Plugin ${pluginName} is not loaded`);
}
try {
// Deactivate plugin first
await this.deactivatePlugin(pluginName);
// Cleanup plugin
await plugin.instance.cleanup();
// Remove from registry
this.plugins.delete(pluginName);
this.logger.info(`Unloaded plugin: ${pluginName}`);
} catch (error) {
this.logger.error(`Failed to unload plugin ${pluginName}:`, error);
throw error;
}
}
async reloadPlugin(pluginName: string): Promise<void> {
const plugin = this.plugins.get(pluginName);
if (!plugin) {
throw new Error(`Plugin ${pluginName} is not loaded`);
}
const pluginPath = this.getPluginPath(pluginName);
if (!pluginPath) {
throw new Error(`Cannot determine path for plugin ${pluginName}`);
}
// Unload and reload
await this.unloadPlugin(pluginName);
await this.loadPlugin(pluginPath);
}
getLoadedPlugins(): LoadedPlugin[] {
return Array.from(this.plugins.values());
}
getPlugin(pluginName: string): LoadedPlugin | undefined {
return this.plugins.get(pluginName);
}
async executeTool(toolName: string, params: any): Promise<any> {
const tool = this.tools.get(toolName);
if (!tool) {
throw new Error(`Tool ${toolName} not found`);
}
try {
this.logger.debug(`Executing tool: ${toolName}`);
return await tool.handler(params);
} catch (error) {
this.logger.error(`Error executing tool ${toolName}:`, error);
throw error;
}
}
private async activatePlugin(pluginName: string): Promise<void> {
const plugin = this.plugins.get(pluginName);
if (!plugin || plugin.state === 'active') {
return;
}
try {
plugin.state = 'active';
plugin.lastActive = new Date();
this.logger.info(`Activated plugin: ${pluginName}`);
// Emit activation event
this.emit(`plugin:activated:${pluginName}`, { pluginName, timestamp: Date.now() });
} catch (error) {
plugin.state = 'error';
plugin.error = error as Error;
this.logger.error(`Failed to activate plugin ${pluginName}:`, error);
throw error;
}
}
private async deactivatePlugin(pluginName: string): Promise<void> {
const plugin = this.plugins.get(pluginName);
if (!plugin || plugin.state === 'inactive') {
return;
}
try {
plugin.state = 'inactive';
this.logger.info(`Deactivated plugin: ${pluginName}`);
// Emit deactivation event
this.emit(`plugin:deactivated:${pluginName}`, { pluginName, timestamp: Date.now() });
} catch (error) {
plugin.state = 'error';
plugin.error = error as Error;
this.logger.error(`Failed to deactivate plugin ${pluginName}:`, error);
throw error;
}
}
private validatePluginInterface(instance: any): instance is PluginInterface {
return (
typeof instance === 'object' &&
typeof instance.name === 'string' &&
typeof instance.version === 'string' &&
typeof instance.initialize === 'function' &&
typeof instance.execute === 'function' &&
typeof instance.cleanup === 'function'
);
}
private extractMetadata(module: any, instance: PluginInterface): PluginMetadata {
// Try to get metadata from package.json if available
const packageJson = module.package || {};
return {
name: instance.name,
version: instance.version,
description: instance.description || packageJson.description || '',
author: packageJson.author || '',
license: packageJson.license || 'MIT',
homepage: packageJson.homepage,
repository: packageJson.repository,
keywords: packageJson.keywords || [],
dependencies: packageJson.dependencies || {},
devDependencies: packageJson.devDependencies || {},
engines: packageJson.engines || { node: '>=18.0.0' }
};
}
private createPluginContext(pluginName: string): PluginContext {
return {
logger: this.logger,
config: this.config,
registerTool: (tool: ToolDefinition) => {
this.tools.set(tool.name, tool);
this.logger.debug(`Plugin ${pluginName} registered tool: ${tool.name}`);
},
unregisterTool: (toolName: string) => {
this.tools.delete(toolName);
this.logger.debug(`Plugin ${pluginName} unregistered tool: ${toolName}`);
},
getTool: (toolName: string) => {
return this.tools.get(toolName);
},
emit: (event: string, data: any) => {
this.emit(event, data);
},
on: (event: string, handler: (data: any) => void) => {
this.on(event, handler);
},
off: (event: string, handler: (data: any) => void) => {
this.off(event, handler);
}
};
}
private getPluginPath(_pluginName: string): string | undefined {
// This is a simplified implementation
// In a real scenario, you'd need to track where plugins were loaded from
return undefined;
}
private emit(event: string, data: any): void {
const handlers = this.eventHandlers.get(event);
if (handlers) {
handlers.forEach(handler => {
try {
handler(data);
} catch (error) {
this.logger.error(`Error in event handler for ${event}:`, error);
}
});
}
}
private on(event: string, handler: (data: any) => void): void {
if (!this.eventHandlers.has(event)) {
this.eventHandlers.set(event, []);
}
this.eventHandlers.get(event)!.push(handler);
}
private off(event: string, handler: (data: any) => void): void {
const handlers = this.eventHandlers.get(event);
if (handlers) {
const index = handlers.indexOf(handler);
if (index > -1) {
handlers.splice(index, 1);
}
}
}
async dispose(): Promise<void> {
// Unload all plugins
const pluginNames = Array.from(this.plugins.keys());
for (const pluginName of pluginNames) {
try {
await this.unloadPlugin(pluginName);
} catch (error) {
this.logger.error(`Error unloading plugin ${pluginName} during disposal:`, error);
}
}
// Clear registries
this.plugins.clear();
this.tools.clear();
this.eventHandlers.clear();
}
}
// Plugin discovery utilities
export class PluginDiscovery {
private logger: LoggerInterface;
constructor(logger: LoggerInterface) {
this.logger = logger;
}
async discoverPlugins(pluginsDir: string): Promise<string[]> {
const fs = require('fs');
const path = require('path');
const pluginPaths: string[] = [];
try {
if (!fs.existsSync(pluginsDir)) {
this.logger.warn(`Plugins directory does not exist: ${pluginsDir}`);
return pluginPaths;
}
const entries = fs.readdirSync(pluginsDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
// Check for package.json or index.js/ts
const pluginDir = path.join(pluginsDir, entry.name);
const packageJsonPath = path.join(pluginDir, 'package.json');
const indexPath = path.join(pluginDir, 'index.js');
const tsIndexPath = path.join(pluginDir, 'index.ts');
if (fs.existsSync(packageJsonPath)) {
pluginPaths.push(pluginDir);
} else if (fs.existsSync(indexPath)) {
pluginPaths.push(indexPath);
} else if (fs.existsSync(tsIndexPath)) {
pluginPaths.push(tsIndexPath);
}
}
}
this.logger.info(`Discovered ${pluginPaths.length} plugins in ${pluginsDir}`);
return pluginPaths;
} catch (error) {
this.logger.error(`Error discovering plugins in ${pluginsDir}:`, error);
return [];
}
}
async validatePlugin(pluginPath: string): Promise<boolean> {
try {
// Try to load the plugin module
const pluginModule = await import(pluginPath);
const PluginClass = pluginModule.default || pluginModule.Plugin;
if (!PluginClass) {
return false;
}
// Try to create an instance
const instance = new PluginClass();
// Check if it implements the required interface
return (
typeof instance.name === 'string' &&
typeof instance.version === 'string' &&
typeof instance.initialize === 'function' &&
typeof instance.execute === 'function' &&
typeof instance.cleanup === 'function'
);
} catch (error) {
this.logger.debug(`Plugin validation failed for ${pluginPath}:`, error);
return false;
}
}
}
// Factory functions
export function createPluginManager(
logger: LoggerInterface,
config: ConfigInterface
): PluginManager {
return new PluginManager(logger, config);
}
export function createPluginDiscovery(
logger: LoggerInterface
): PluginDiscovery {
return new PluginDiscovery(logger);
}
// Base plugin class for easier plugin development
export abstract class BasePlugin implements PluginInterface {
abstract name: string;
abstract version: string;
abstract description: string;
protected context?: PluginContext;
async initialize(_config: Record<string, any>): Promise<void> {
// Default implementation - can be overridden
}
abstract execute(request: MCPRequest): Promise<MCPResponse>;
async cleanup(): Promise<void> {
// Default implementation - can be overridden
}
protected registerTool(tool: ToolDefinition): void {
if (this.context) {
this.context.registerTool(tool);
}
}
protected unregisterTool(toolName: string): void {
if (this.context) {
this.context.unregisterTool(toolName);
}
}
protected emit(event: string, data: any): void {
if (this.context) {
this.context.emit(event, data);
}
}
protected on(event: string, handler: (data: any) => void): void {
if (this.context) {
this.context.on(event, handler);
}
}
protected off(event: string, handler: (data: any) => void): void {
if (this.context) {
this.context.off(event, handler);
}
}
// Helper method to create successful responses
protected createSuccessResponse(result: any, requestId: string): MCPResponse {
return {
id: requestId,
result,
timestamp: new Date()
};
}
// Helper method to create error responses
protected createErrorResponse(
code: number,
message: string,
requestId: string,
data?: any
): MCPResponse {
return {
id: requestId,
error: {
code,
message,
data
},
timestamp: new Date()
};
}
}