|

Implementing Plugin Solutions for MCP Servers

Last time we built an MCP server. Now let’s make it actually useful by adding plugins. MCP’s real power is letting servers expose Tools, Resources, and Prompts that AI models can discover and use. Plugins are how you organize all that.

Why plugins?

As your MCP server grows, managing everything in one place gets messy fast. Plugins solve this:

  • Each plugin handles its own tools, resources, and prompts
  • Teams can work on plugins independently
  • Add or remove capabilities without redeploying
  • Let other devs extend your server
  • Test plugins in isolation

Think of a plugin as a self-contained package of AI capabilities.

interface MCPPlugin {
  name: string
  version: string
  initialize(context: PluginContext): Promise<void>
  start(): Promise<void>
  stop(): Promise<void>
  destroy(): Promise<void>
}

interface PluginContext {
  server: MCPServer
  config: PluginConfig
  logger: Logger
  registerTool(tool: ToolDefinition): void
  registerResource(resource: ResourceDefinition): void
  registerPrompt(prompt: PromptDefinition): void
}

interface ToolDefinition {
  name: string
  description: string
  inputSchema: JSONSchema
  handler: (params: unknown) => Promise<unknown>
}

interface ResourceDefinition {
  uri: string
  name: string
  description: string
  mimeType: string
  handler: () => Promise<string | Buffer>
}

interface PromptDefinition {
  name: string
  description: string
  arguments: PromptArgument[]
  handler: (args: Record<string, unknown>) => Promise<PromptMessage[]>
}

What are Tools, Resources, and Prompts?

Tools are actions your server can do. The AI model calls them when it needs something done. Each tool needs user approval before running.

Resources are read-only data. Files, database records, API responses - anything the model might need to read.

Prompts are reusable instruction templates. They help the AI do common tasks consistently.

The capability registry

This is where all your tools, resources, and prompts live:

class MCPCapabilityRegistry {
  private tools: Map<string, ToolDefinition> = new Map()
  private resources: Map<string, ResourceDefinition> = new Map()
  private prompts: Map<string, PromptDefinition> = new Map()

  registerTool(tool: ToolDefinition): void {
    if (this.tools.has(tool.name)) {
      throw new Error(`Tool ${tool.name} already registered`)
    }
    this.tools.set(tool.name, tool)
  }

  registerResource(resource: ResourceDefinition): void {
    if (this.resources.has(resource.uri)) {
      throw new Error(`Resource ${resource.uri} already registered`)
    }
    this.resources.set(resource.uri, resource)
  }

  registerPrompt(prompt: PromptDefinition): void {
    if (this.prompts.has(prompt.name)) {
      throw new Error(`Prompt ${prompt.name} already registered`)
    }
    this.prompts.set(prompt.name, prompt)
  }

  listTools(): ToolDefinition[] {
    return Array.from(this.tools.values())
  }

  listResources(): ResourceDefinition[] {
    return Array.from(this.resources.values())
  }

  listPrompts(): PromptDefinition[] {
    return Array.from(this.prompts.values())
  }

  getTool(name: string): ToolDefinition | undefined {
    return this.tools.get(name)
  }

  getResource(uri: string): ResourceDefinition | undefined {
    return this.resources.get(uri)
  }

  getPrompt(name: string): PromptDefinition | undefined {
    return this.prompts.get(name)
  }

  unregisterTool(name: string): void {
    this.tools.delete(name)
  }

  unregisterResource(uri: string): void {
    this.resources.delete(uri)
  }

  unregisterPrompt(name: string): void {
    this.prompts.delete(name)
  }
}

Loading plugins dynamically

Load plugins at runtime without restarting:

class MCPPluginLoader {
  constructor(
    private registry: MCPCapabilityRegistry,
    private pluginDir: string
  ) {}

  async loadPlugin(pluginPath: string): Promise<MCPPlugin> {
    try {
      const absolutePath = path.resolve(this.pluginDir, pluginPath)
      
      if (!this.isValidPluginPath(absolutePath)) {
        throw new Error('Invalid plugin path')
      }

      const pluginModule = await import(absolutePath)
      
      if (!this.isValidMCPPlugin(pluginModule.default)) {
        throw new Error('Invalid MCP plugin structure')
      }

      const plugin = pluginModule.default as MCPPlugin

      return plugin
    } catch (error) {
      throw new Error(`Failed to load MCP plugin: ${error.message}`)
    }
  }

  async loadAllPlugins(): Promise<MCPPlugin[]> {
    const pluginFiles = await this.discoverPlugins()
    const plugins: MCPPlugin[] = []

    for (const file of pluginFiles) {
      try {
        const plugin = await this.loadPlugin(file)
        plugins.push(plugin)
      } catch (error) {
        console.error(`Failed to load plugin ${file}:`, error)
      }
    }

    return plugins
  }

  private async discoverPlugins(): Promise<string[]> {
    const files = await fs.readdir(this.pluginDir)
    
    return files.filter(file => 
      file.endsWith('.js') || file.endsWith('.ts')
    )
  }

  private isValidPluginPath(pluginPath: string): boolean {
    const normalized = path.normalize(pluginPath)
    return normalized.startsWith(path.normalize(this.pluginDir))
  }

  private isValidMCPPlugin(plugin: any): boolean {
    return (
      plugin &&
      typeof plugin.name === 'string' &&
      typeof plugin.version === 'string' &&
      typeof plugin.initialize === 'function' &&
      typeof plugin.start === 'function' &&
      typeof plugin.stop === 'function' &&
      typeof plugin.destroy === 'function'
    )
  }
}

Managing plugin lifecycle

Plugins need proper startup and shutdown:

class MCPPluginLifecycleManager {
  private plugins: Map<string, MCPPlugin> = new Map()

  async initializePlugin(
    plugin: MCPPlugin,
    context: PluginContext
  ): Promise<void> {
    try {
      await plugin.initialize(context)
      this.plugins.set(plugin.name, plugin)
    } catch (error) {
      throw new Error(
        `Failed to initialize plugin ${plugin.name}: ${error.message}`
      )
    }
  }

  async startPlugin(pluginName: string): Promise<void> {
    const plugin = this.plugins.get(pluginName)
    if (!plugin) {
      throw new Error(`Plugin ${pluginName} not found`)
    }

    try {
      await plugin.start()
    } catch (error) {
      throw new Error(`Failed to start plugin ${pluginName}: ${error.message}`)
    }
  }

  async stopPlugin(pluginName: string): Promise<void> {
    const plugin = this.plugins.get(pluginName)
    if (!plugin) return

    try {
      await plugin.stop()
    } catch (error) {
      console.error(`Error stopping plugin ${pluginName}:`, error)
    }
  }

  async destroyPlugin(pluginName: string): Promise<void> {
    const plugin = this.plugins.get(pluginName)
    if (!plugin) return

    try {
      await plugin.destroy()
      this.plugins.delete(pluginName)
    } catch (error) {
      console.error(`Error destroying plugin ${pluginName}:`, error)
    }
  }

  async startAll(): Promise<void> {
    for (const plugin of this.plugins.values()) {
      await this.startPlugin(plugin.name)
    }
  }

  async stopAll(): Promise<void> {
    const plugins = Array.from(this.plugins.values()).reverse()

    for (const plugin of plugins) {
      await this.stopPlugin(plugin.name)
    }
  }

  async destroyAll(): Promise<void> {
    const plugins = Array.from(this.plugins.values()).reverse()

    for (const plugin of plugins) {
      await this.destroyPlugin(plugin.name)
    }
  }
}

Real plugin examples

Let’s build some actual plugins:

Database Plugin - Query your database from AI:

class DatabasePlugin implements MCPPlugin {
  name = 'database'
  version = '1.0.0'
  private db: Database | null = null

  async initialize(context: PluginContext): Promise<void> {
    this.db = new Database(context.config.settings.connectionString)

    context.registerTool({
      name: 'query_database',
      description: 'Execute a SQL query against the database',
      inputSchema: {
        type: 'object',
        properties: {
          query: { type: 'string', description: 'SQL query to execute' },
          params: { type: 'array', description: 'Query parameters' }
        },
        required: ['query']
      },
      handler: async (params: any) => {
        const results = await this.db!.query(params.query, params.params || [])
        return { rows: results, count: results.length }
      }
    })

    context.registerResource({
      uri: 'db://schema',
      name: 'database_schema',
      description: 'Database schema information',
      mimeType: 'application/json',
      handler: async () => {
        const schema = await this.db!.getSchema()
        return JSON.stringify(schema, null, 2)
      }
    })

    context.registerPrompt({
      name: 'generate_query',
      description: 'Generate a SQL query based on natural language description',
      arguments: [
        {
          name: 'description',
          description: 'Natural language description of the query',
          required: true
        }
      ],
      handler: async (args) => {
        return [
          {
            role: 'user',
            content: {
              type: 'text',
              text: `Generate a SQL query for: ${args.description}\n\nUse the database schema from the db://schema resource.`
            }
          }
        ]
      }
    })
  }

  async start(): Promise<void> {
    await this.db!.connect()
  }

  async stop(): Promise<void> {
    await this.db!.disconnect()
  }

  async destroy(): Promise<void> {
    this.db = null
  }
}

File System Plugin - Read and write files:

class FileSystemPlugin implements MCPPlugin {
  name = 'filesystem'
  version = '1.0.0'
  private rootPath: string = ''

  async initialize(context: PluginContext): Promise<void> {
    this.rootPath = context.config.settings.rootPath

    context.registerTool({
      name: 'read_file',
      description: 'Read contents of a file',
      inputSchema: {
        type: 'object',
        properties: {
          path: { type: 'string', description: 'File path relative to root' }
        },
        required: ['path']
      },
      handler: async (params: any) => {
        const fullPath = path.join(this.rootPath, params.path)
        const content = await fs.readFile(fullPath, 'utf-8')
        return { content, path: params.path }
      }
    })

    context.registerTool({
      name: 'write_file',
      description: 'Write contents to a file',
      inputSchema: {
        type: 'object',
        properties: {
          path: { type: 'string', description: 'File path relative to root' },
          content: { type: 'string', description: 'File content to write' }
        },
        required: ['path', 'content']
      },
      handler: async (params: any) => {
        const fullPath = path.join(this.rootPath, params.path)
        await fs.writeFile(fullPath, params.content, 'utf-8')
        return { success: true, path: params.path }
      }
    })

    context.registerResource({
      uri: 'file://directory-listing',
      name: 'directory_listing',
      description: 'List of all files in the root directory',
      mimeType: 'application/json',
      handler: async () => {
        const files = await this.listFiles(this.rootPath)
        return JSON.stringify(files, null, 2)
      }
    })
  }

  private async listFiles(dir: string): Promise<string[]> {
    const entries = await fs.readdir(dir, { withFileTypes: true })
    const files: string[] = []

    for (const entry of entries) {
      const fullPath = path.join(dir, entry.name)
      if (entry.isDirectory()) {
        const subFiles = await this.listFiles(fullPath)
        files.push(...subFiles)
      } else {
        files.push(path.relative(this.rootPath, fullPath))
      }
    }

    return files
  }

  async start(): Promise<void> {
    // Verify root path exists
    await fs.access(this.rootPath)
  }

  async stop(): Promise<void> {
    // Cleanup if needed
  }

  async destroy(): Promise<void> {
    this.rootPath = ''
  }
}

Weather API Plugin - Get weather data:

class WeatherAPIPlugin implements MCPPlugin {
  name = 'weather'
  version = '1.0.0'
  private apiKey: string = ''
  private apiClient: any = null

  async initialize(context: PluginContext): Promise<void> {
    this.apiKey = context.config.settings.apiKey
    this.apiClient = new WeatherAPIClient(this.apiKey)

    context.registerTool({
      name: 'get_weather',
      description: 'Get current weather for a location',
      inputSchema: {
        type: 'object',
        properties: {
          location: { type: 'string', description: 'City name or coordinates' },
          units: { 
            type: 'string', 
            enum: ['metric', 'imperial'],
            description: 'Temperature units'
          }
        },
        required: ['location']
      },
      handler: async (params: any) => {
        const weather = await this.apiClient.getCurrentWeather(
          params.location,
          params.units || 'metric'
        )
        return weather
      }
    })

    context.registerTool({
      name: 'get_forecast',
      description: 'Get weather forecast for a location',
      inputSchema: {
        type: 'object',
        properties: {
          location: { type: 'string', description: 'City name or coordinates' },
          days: { type: 'number', description: 'Number of days (1-7)' }
        },
        required: ['location']
      },
      handler: async (params: any) => {
        const forecast = await this.apiClient.getForecast(
          params.location,
          params.days || 3
        )
        return forecast
      }
    })

    context.registerPrompt({
      name: 'weather_report',
      description: 'Generate a weather report for a location',
      arguments: [
        {
          name: 'location',
          description: 'Location for the weather report',
          required: true
        }
      ],
      handler: async (args) => {
        return [
          {
            role: 'user',
            content: {
              type: 'text',
              text: `Use the get_weather and get_forecast tools to create a comprehensive weather report for ${args.location}. Include current conditions and a 3-day forecast.`
            }
          }
        ]
      }
    })
  }

  async start(): Promise<void> {
    // Verify API key is valid
    await this.apiClient.testConnection()
  }

  async stop(): Promise<void> {
    // Cleanup connections
  }

  async destroy(): Promise<void> {
    this.apiClient = null
  }
}

Handling MCP protocol

Your server needs to respond to MCP protocol methods:

class MCPServer {
  constructor(
    private registry: MCPCapabilityRegistry,
    private lifecycle: MCPPluginLifecycleManager
  ) {}

  async handleRequest(request: MCPRequest): Promise<MCPResponse> {
    switch (request.method) {
      case 'tools/list':
        return this.handleToolsList()
      
      case 'tools/call':
        return this.handleToolsCall(request.params)
      
      case 'resources/list':
        return this.handleResourcesList()
      
      case 'resources/read':
        return this.handleResourcesRead(request.params)
      
      case 'prompts/list':
        return this.handlePromptsList()
      
      case 'prompts/get':
        return this.handlePromptsGet(request.params)
      
      default:
        return {
          id: request.id,
          error: {
            code: -32601,
            message: `Method not found: ${request.method}`
          }
        }
    }
  }

  private async handleToolsList(): Promise<MCPResponse> {
    const tools = this.registry.listTools().map(tool => ({
      name: tool.name,
      description: tool.description,
      inputSchema: tool.inputSchema
    }))

    return {
      id: 'tools-list',
      result: { tools }
    }
  }

  private async handleToolsCall(params: any): Promise<MCPResponse> {
    const tool = this.registry.getTool(params.name)
    
    if (!tool) {
      return {
        id: 'tools-call',
        error: {
          code: -32602,
          message: `Tool not found: ${params.name}`
        }
      }
    }

    try {
      const result = await tool.handler(params.arguments)
      
      return {
        id: 'tools-call',
        result: {
          content: [
            {
              type: 'text',
              text: JSON.stringify(result, null, 2)
            }
          ]
        }
      }
    } catch (error) {
      return {
        id: 'tools-call',
        error: {
          code: -32000,
          message: error.message
        }
      }
    }
  }

  private async handleResourcesList(): Promise<MCPResponse> {
    const resources = this.registry.listResources().map(resource => ({
      uri: resource.uri,
      name: resource.name,
      description: resource.description,
      mimeType: resource.mimeType
    }))

    return {
      id: 'resources-list',
      result: { resources }
    }
  }

  private async handleResourcesRead(params: any): Promise<MCPResponse> {
    const resource = this.registry.getResource(params.uri)
    
    if (!resource) {
      return {
        id: 'resources-read',
        error: {
          code: -32602,
          message: `Resource not found: ${params.uri}`
        }
      }
    }

    try {
      const content = await resource.handler()
      
      return {
        id: 'resources-read',
        result: {
          contents: [
            {
              uri: resource.uri,
              mimeType: resource.mimeType,
              text: typeof content === 'string' ? content : content.toString()
            }
          ]
        }
      }
    } catch (error) {
      return {
        id: 'resources-read',
        error: {
          code: -32000,
          message: error.message
        }
      }
    }
  }

  private async handlePromptsList(): Promise<MCPResponse> {
    const prompts = this.registry.listPrompts().map(prompt => ({
      name: prompt.name,
      description: prompt.description,
      arguments: prompt.arguments
    }))

    return {
      id: 'prompts-list',
      result: { prompts }
    }
  }

  private async handlePromptsGet(params: any): Promise<MCPResponse> {
    const prompt = this.registry.getPrompt(params.name)
    
    if (!prompt) {
      return {
        id: 'prompts-get',
        error: {
          code: -32602,
          message: `Prompt not found: ${params.name}`
        }
      }
    }

    try {
      const messages = await prompt.handler(params.arguments || {})
      
      return {
        id: 'prompts-get',
        result: {
          description: prompt.description,
          messages
        }
      }
    } catch (error) {
      return {
        id: 'prompts-get',
        error: {
          code: -32000,
          message: error.message
        }
      }
    }
  }
}

Testing plugins

Test that your plugins actually work:

describe('MCP Plugin System', () => {
  let registry: MCPCapabilityRegistry
  let loader: MCPPluginLoader
  let lifecycle: MCPPluginLifecycleManager

  beforeEach(() => {
    registry = new MCPCapabilityRegistry()
    loader = new MCPPluginLoader(registry, './plugins')
    lifecycle = new MCPPluginLifecycleManager()
  })

  it('should register tools from plugin', async () => {
    const plugin = new DatabasePlugin()
    const context = createMockContext(registry)

    await lifecycle.initializePlugin(plugin, context)

    const tools = registry.listTools()
    expect(tools).toHaveLength(1)
    expect(tools[0].name).toBe('query_database')
  })

  it('should register resources from plugin', async () => {
    const plugin = new DatabasePlugin()
    const context = createMockContext(registry)

    await lifecycle.initializePlugin(plugin, context)

    const resources = registry.listResources()
    expect(resources).toHaveLength(1)
    expect(resources[0].uri).toBe('db://schema')
  })

  it('should register prompts from plugin', async () => {
    const plugin = new DatabasePlugin()
    const context = createMockContext(registry)

    await lifecycle.initializePlugin(plugin, context)

    const prompts = registry.listPrompts()
    expect(prompts).toHaveLength(1)
    expect(prompts[0].name).toBe('generate_query')
  })

  it('should invoke tool handler', async () => {
    const plugin = new DatabasePlugin()
    const context = createMockContext(registry)

    await lifecycle.initializePlugin(plugin, context)
    await lifecycle.startPlugin(plugin.name)

    const tool = registry.getTool('query_database')
    const result = await tool!.handler({ query: 'SELECT * FROM users' })

    expect(result).toHaveProperty('rows')
    expect(result).toHaveProperty('count')
  })
})

What works (and what doesn’t)

Do this:

  • Keep tools focused on one thing
  • Write clear descriptions - the AI needs to understand what your tool does
  • Provide good JSON schemas for inputs
  • Make resources fast and cacheable
  • Create prompts that guide the model to use your tools effectively
  • Return useful error messages
  • Document how long tools take to run
  • Version your APIs when you make breaking changes

Don’t do this:

  • Make tools that do too much - break them up
  • Skip input validation - always validate
  • Put expensive operations in resource handlers
  • Write vague prompts
  • Swallow errors - let them bubble up
  • Let plugin state leak between requests
  • Forget to write descriptions

Wrapping up

MCP plugins turn your server into a platform. Tools, resources, and prompts become modular packages that AI models can discover and use.

The patterns here - capability registries, dynamic loading, protocol handling - they work in production. Start with simple plugins, grow from there.

Next up: doing all this with NestJS, which makes it even cleaner with dependency injection and decorators.

Keep pushing forward and savor every step of your coding journey.