|

Building MCP Plugin Solutions with NestJS

We’ve built MCP servers and plugins. Now let’s do it the NestJS way. If you’ve used NestJS, you know it brings some serious structure to Node.js. Dependency injection, decorators, modules - it’s perfect for MCP servers.

Why NestJS for MCP?

NestJS gives you enterprise patterns without the enterprise pain:

  • Dependency Injection: No more manual wiring of plugin dependencies
  • Modules: Organize your MCP capabilities cleanly
  • Decorators: Define tools, resources, and prompts declaratively
  • Testing: First-class testing support built in
  • TypeScript: Strong typing everywhere
  • Lifecycle Hooks: Clean plugin startup and shutdown

Here’s a taste:

import { Module, Injectable } from '@nestjs/common'

@Injectable()
class WeatherPlugin {
  @MCPTool({
    name: 'get_weather',
    description: 'Get current weather for a location'
  })
  async getWeather(location: string): Promise<WeatherData> {
    // Implementation
  }
}

@Module({
  providers: [WeatherPlugin],
  exports: [WeatherPlugin]
})
class WeatherModule {}

Setting it up

Start fresh:

npm i -g @nestjs/cli
nest new mcp-server
cd mcp-server
npm install @modelcontextprotocol/sdk zod class-validator class-transformer

Enable decorators in tsconfig.json:

{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "target": "ES2021",
    "module": "commonjs",
    "strict": true,
    "esModuleInterop": true
  }
}

Custom decorators for MCP

This is where it gets fun. We’ll create decorators to mark methods as MCP capabilities:

import { SetMetadata } from '@nestjs/common'
import { z } from 'zod'

export const MCP_TOOL_METADATA = 'mcp:tool'
export const MCP_RESOURCE_METADATA = 'mcp:resource'
export const MCP_PROMPT_METADATA = 'mcp:prompt'

export interface MCPToolOptions {
  name: string
  description: string
  inputSchema?: z.ZodSchema
}

export interface MCPResourceOptions {
  uri: string
  name: string
  description: string
  mimeType: string
}

export interface MCPPromptOptions {
  name: string
  description: string
  arguments?: Array<{
    name: string
    description: string
    required: boolean
  }>
}

export function MCPTool(options: MCPToolOptions): MethodDecorator {
  return (target, propertyKey, descriptor) => {
    SetMetadata(MCP_TOOL_METADATA, options)(target, propertyKey, descriptor)
    return descriptor
  }
}

export function MCPResource(options: MCPResourceOptions): MethodDecorator {
  return (target, propertyKey, descriptor) => {
    SetMetadata(MCP_RESOURCE_METADATA, options)(target, propertyKey, descriptor)
    return descriptor
  }
}

export function MCPPrompt(options: MCPPromptOptions): MethodDecorator {
  return (target, propertyKey, descriptor) => {
    SetMetadata(MCP_PROMPT_METADATA, options)(target, propertyKey, descriptor)
    return descriptor
  }
}

Auto-discovering capabilities

Build a service that finds all your decorated methods:

import { Injectable, OnModuleInit } from '@nestjs/common'
import { DiscoveryService, MetadataScanner, Reflector } from '@nestjs/core'
import { InstanceWrapper } from '@nestjs/core/injector/instance-wrapper'

@Injectable()
export class MCPDiscoveryService implements OnModuleInit {
  private tools: Map<string, ToolDefinition> = new Map()
  private resources: Map<string, ResourceDefinition> = new Map()
  private prompts: Map<string, PromptDefinition> = new Map()

  constructor(
    private readonly discoveryService: DiscoveryService,
    private readonly metadataScanner: MetadataScanner,
    private readonly reflector: Reflector
  ) {}

  async onModuleInit() {
    await this.discoverCapabilities()
  }

  private async discoverCapabilities() {
    const providers = this.discoveryService.getProviders()

    for (const wrapper of providers) {
      const { instance } = wrapper
      if (!instance || !Object.getPrototypeOf(instance)) {
        continue
      }

      this.scanForTools(instance, wrapper)
      this.scanForResources(instance, wrapper)
      this.scanForPrompts(instance, wrapper)
    }
  }

  private scanForTools(instance: any, wrapper: InstanceWrapper) {
    const prototype = Object.getPrototypeOf(instance)
    const methodNames = this.metadataScanner.getAllMethodNames(prototype)

    for (const methodName of methodNames) {
      const metadata = this.reflector.get<MCPToolOptions>(
        MCP_TOOL_METADATA,
        instance[methodName]
      )

      if (metadata) {
        this.tools.set(metadata.name, {
          name: metadata.name,
          description: metadata.description,
          inputSchema: metadata.inputSchema,
          handler: instance[methodName].bind(instance)
        })
      }
    }
  }

  private scanForResources(instance: any, wrapper: InstanceWrapper) {
    const prototype = Object.getPrototypeOf(instance)
    const methodNames = this.metadataScanner.getAllMethodNames(prototype)

    for (const methodName of methodNames) {
      const metadata = this.reflector.get<MCPResourceOptions>(
        MCP_RESOURCE_METADATA,
        instance[methodName]
      )

      if (metadata) {
        this.resources.set(metadata.uri, {
          uri: metadata.uri,
          name: metadata.name,
          description: metadata.description,
          mimeType: metadata.mimeType,
          handler: instance[methodName].bind(instance)
        })
      }
    }
  }

  private scanForPrompts(instance: any, wrapper: InstanceWrapper) {
    const prototype = Object.getPrototypeOf(instance)
    const methodNames = this.metadataScanner.getAllMethodNames(prototype)

    for (const methodName of methodNames) {
      const metadata = this.reflector.get<MCPPromptOptions>(
        MCP_PROMPT_METADATA,
        instance[methodName]
      )

      if (metadata) {
        this.prompts.set(metadata.name, {
          name: metadata.name,
          description: metadata.description,
          arguments: metadata.arguments || [],
          handler: instance[methodName].bind(instance)
        })
      }
    }
  }

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

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

  getPrompts(): 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)
  }
}

Building plugin modules

Now we can build actual plugins as NestJS modules:

import { Injectable, Module } from '@nestjs/common'
import { z } from 'zod'

// Database Plugin
@Injectable()
export class DatabasePlugin {
  constructor(private readonly databaseService: DatabaseService) {}

  @MCPTool({
    name: 'query_database',
    description: 'Execute a SQL query against the database',
    inputSchema: z.object({
      query: z.string().describe('SQL query to execute'),
      params: z.array(z.any()).optional().describe('Query parameters')
    })
  })
  async queryDatabase(params: { query: string; params?: any[] }) {
    const results = await this.databaseService.query(
      params.query,
      params.params || []
    )
    return {
      rows: results,
      count: results.length
    }
  }

  @MCPResource({
    uri: 'db://schema',
    name: 'database_schema',
    description: 'Database schema information',
    mimeType: 'application/json'
  })
  async getDatabaseSchema() {
    const schema = await this.databaseService.getSchema()
    return JSON.stringify(schema, null, 2)
  }

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

@Module({
  providers: [DatabasePlugin, DatabaseService],
  exports: [DatabasePlugin]
})
export class DatabasePluginModule {}

File System Plugin Module

import { Injectable, Module } from '@nestjs/common'
import { z } from 'zod'
import * as fs from 'fs/promises'
import * as path from 'path'

@Injectable()
export class FileSystemPlugin {
  constructor(
    @Inject('FILE_SYSTEM_ROOT') private readonly rootPath: string
  ) {}

  @MCPTool({
    name: 'read_file',
    description: 'Read contents of a file',
    inputSchema: z.object({
      path: z.string().describe('File path relative to root')
    })
  })
  async readFile(params: { path: string }) {
    const fullPath = path.join(this.rootPath, params.path)
    const content = await fs.readFile(fullPath, 'utf-8')
    return {
      content,
      path: params.path
    }
  }

  @MCPTool({
    name: 'write_file',
    description: 'Write contents to a file',
    inputSchema: z.object({
      path: z.string().describe('File path relative to root'),
      content: z.string().describe('File content to write')
    })
  })
  async writeFile(params: { path: string; content: string }) {
    const fullPath = path.join(this.rootPath, params.path)
    await fs.writeFile(fullPath, params.content, 'utf-8')
    return {
      success: true,
      path: params.path
    }
  }

  @MCPTool({
    name: 'list_files',
    description: 'List files in a directory',
    inputSchema: z.object({
      path: z.string().optional().describe('Directory path (defaults to root)')
    })
  })
  async listFiles(params: { path?: string }) {
    const dirPath = params.path
      ? path.join(this.rootPath, params.path)
      : this.rootPath

    const entries = await fs.readdir(dirPath, { withFileTypes: true })

    return {
      files: entries
        .filter(e => e.isFile())
        .map(e => e.name),
      directories: entries
        .filter(e => e.isDirectory())
        .map(e => e.name)
    }
  }

  @MCPResource({
    uri: 'file://directory-tree',
    name: 'directory_tree',
    description: 'Complete directory tree structure',
    mimeType: 'application/json'
  })
  async getDirectoryTree() {
    const tree = await this.buildDirectoryTree(this.rootPath)
    return JSON.stringify(tree, null, 2)
  }

  private async buildDirectoryTree(dir: string): Promise<any> {
    const entries = await fs.readdir(dir, { withFileTypes: true })
    const tree: any = {
      name: path.basename(dir),
      type: 'directory',
      children: []
    }

    for (const entry of entries) {
      const fullPath = path.join(dir, entry.name)
      if (entry.isDirectory()) {
        tree.children.push(await this.buildDirectoryTree(fullPath))
      } else {
        tree.children.push({
          name: entry.name,
          type: 'file'
        })
      }
    }

    return tree
  }
}

@Module({
  providers: [
    FileSystemPlugin,
    {
      provide: 'FILE_SYSTEM_ROOT',
      useValue: process.env.FS_ROOT || './data'
    }
  ],
  exports: [FileSystemPlugin]
})
export class FileSystemPluginModule {}

Weather API Plugin Module

import { Injectable, Module, HttpService } from '@nestjs/common'
import { z } from 'zod'

@Injectable()
export class WeatherPlugin {
  constructor(
    private readonly httpService: HttpService,
    @Inject('WEATHER_API_KEY') private readonly apiKey: string
  ) {}

  @MCPTool({
    name: 'get_weather',
    description: 'Get current weather for a location',
    inputSchema: z.object({
      location: z.string().describe('City name or coordinates'),
      units: z.enum(['metric', 'imperial']).optional().describe('Temperature units')
    })
  })
  async getWeather(params: { location: string; units?: string }) {
    const response = await this.httpService.axiosRef.get(
      `https://api.weatherapi.com/v1/current.json`,
      {
        params: {
          key: this.apiKey,
          q: params.location,
          units: params.units || 'metric'
        }
      }
    )

    return {
      location: response.data.location.name,
      temperature: response.data.current.temp_c,
      condition: response.data.current.condition.text,
      humidity: response.data.current.humidity,
      wind_speed: response.data.current.wind_kph
    }
  }

  @MCPTool({
    name: 'get_forecast',
    description: 'Get weather forecast for a location',
    inputSchema: z.object({
      location: z.string().describe('City name or coordinates'),
      days: z.number().min(1).max(7).optional().describe('Number of days')
    })
  })
  async getForecast(params: { location: string; days?: number }) {
    const response = await this.httpService.axiosRef.get(
      `https://api.weatherapi.com/v1/forecast.json`,
      {
        params: {
          key: this.apiKey,
          q: params.location,
          days: params.days || 3
        }
      }
    )

    return {
      location: response.data.location.name,
      forecast: response.data.forecast.forecastday.map((day: any) => ({
        date: day.date,
        max_temp: day.day.maxtemp_c,
        min_temp: day.day.mintemp_c,
        condition: day.day.condition.text,
        chance_of_rain: day.day.daily_chance_of_rain
      }))
    }
  }

  @MCPPrompt({
    name: 'weather_report',
    description: 'Generate a comprehensive weather report',
    arguments: [
      {
        name: 'location',
        description: 'Location for the weather report',
        required: true
      }
    ]
  })
  async weatherReportPrompt(args: { location: string }) {
    return [
      {
        role: 'user',
        content: {
          type: 'text',
          text: `Create a comprehensive weather report for ${args.location}. Use the get_weather and get_forecast tools to gather current conditions and a 3-day forecast. Format the report in a user-friendly way.`
        }
      }
    ]
  }
}

@Module({
  imports: [HttpModule],
  providers: [
    WeatherPlugin,
    {
      provide: 'WEATHER_API_KEY',
      useValue: process.env.WEATHER_API_KEY
    }
  ],
  exports: [WeatherPlugin]
})
export class WeatherPluginModule {}

The MCP controller

Handle MCP protocol requests:

import { Controller, Post, Body } from '@nestjs/common'
import { MCPDiscoveryService } from './mcp-discovery.service'

interface MCPRequest {
  jsonrpc: '2.0'
  id: string | number
  method: string
  params?: any
}

interface MCPResponse {
  jsonrpc: '2.0'
  id: string | number
  result?: any
  error?: {
    code: number
    message: string
    data?: any
  }
}

@Controller('mcp')
export class MCPController {
  constructor(private readonly discoveryService: MCPDiscoveryService) {}

  @Post()
  async handleRequest(@Body() request: MCPRequest): Promise<MCPResponse> {
    try {
      switch (request.method) {
        case 'tools/list':
          return this.handleToolsList(request)

        case 'tools/call':
          return await this.handleToolsCall(request)

        case 'resources/list':
          return this.handleResourcesList(request)

        case 'resources/read':
          return await this.handleResourcesRead(request)

        case 'prompts/list':
          return this.handlePromptsList(request)

        case 'prompts/get':
          return await this.handlePromptsGet(request)

        default:
          return {
            jsonrpc: '2.0',
            id: request.id,
            error: {
              code: -32601,
              message: `Method not found: ${request.method}`
            }
          }
      }
    } catch (error) {
      return {
        jsonrpc: '2.0',
        id: request.id,
        error: {
          code: -32000,
          message: error.message,
          data: { stack: error.stack }
        }
      }
    }
  }

  private handleToolsList(request: MCPRequest): MCPResponse {
    const tools = this.discoveryService.getTools().map(tool => ({
      name: tool.name,
      description: tool.description,
      inputSchema: tool.inputSchema
    }))

    return {
      jsonrpc: '2.0',
      id: request.id,
      result: { tools }
    }
  }

  private async handleToolsCall(request: MCPRequest): Promise<MCPResponse> {
    const { name, arguments: args } = request.params

    const tool = this.discoveryService.getTool(name)
    if (!tool) {
      return {
        jsonrpc: '2.0',
        id: request.id,
        error: {
          code: -32602,
          message: `Tool not found: ${name}`
        }
      }
    }

    try {
      const result = await tool.handler(args)

      return {
        jsonrpc: '2.0',
        id: request.id,
        result: {
          content: [
            {
              type: 'text',
              text: JSON.stringify(result, null, 2)
            }
          ]
        }
      }
    } catch (error) {
      return {
        jsonrpc: '2.0',
        id: request.id,
        error: {
          code: -32000,
          message: error.message
        }
      }
    }
  }

  private handleResourcesList(request: MCPRequest): MCPResponse {
    const resources = this.discoveryService.getResources().map(resource => ({
      uri: resource.uri,
      name: resource.name,
      description: resource.description,
      mimeType: resource.mimeType
    }))

    return {
      jsonrpc: '2.0',
      id: request.id,
      result: { resources }
    }
  }

  private async handleResourcesRead(request: MCPRequest): Promise<MCPResponse> {
    const { uri } = request.params

    const resource = this.discoveryService.getResource(uri)
    if (!resource) {
      return {
        jsonrpc: '2.0',
        id: request.id,
        error: {
          code: -32602,
          message: `Resource not found: ${uri}`
        }
      }
    }

    try {
      const content = await resource.handler()

      return {
        jsonrpc: '2.0',
        id: request.id,
        result: {
          contents: [
            {
              uri: resource.uri,
              mimeType: resource.mimeType,
              text: typeof content === 'string' ? content : content.toString()
            }
          ]
        }
      }
    } catch (error) {
      return {
        jsonrpc: '2.0',
        id: request.id,
        error: {
          code: -32000,
          message: error.message
        }
      }
    }
  }

  private handlePromptsList(request: MCPRequest): MCPResponse {
    const prompts = this.discoveryService.getPrompts().map(prompt => ({
      name: prompt.name,
      description: prompt.description,
      arguments: prompt.arguments
    }))

    return {
      jsonrpc: '2.0',
      id: request.id,
      result: { prompts }
    }
  }

  private async handlePromptsGet(request: MCPRequest): Promise<MCPResponse> {
    const { name, arguments: args } = request.params

    const prompt = this.discoveryService.getPrompt(name)
    if (!prompt) {
      return {
        jsonrpc: '2.0',
        id: request.id,
        error: {
          code: -32602,
          message: `Prompt not found: ${name}`
        }
      }
    }

    try {
      const messages = await prompt.handler(args || {})

      return {
        jsonrpc: '2.0',
        id: request.id,
        result: {
          description: prompt.description,
          messages
        }
      }
    } catch (error) {
      return {
        jsonrpc: '2.0',
        id: request.id,
        error: {
          code: -32000,
          message: error.message
        }
      }
    }
  }
}

Wire it all together

Main app module:

import { Module } from '@nestjs/common'
import { DiscoveryModule } from '@nestjs/core'
import { MCPDiscoveryService } from './mcp-discovery.service'
import { MCPController } from './mcp.controller'
import { DatabasePluginModule } from './plugins/database/database-plugin.module'
import { FileSystemPluginModule } from './plugins/filesystem/filesystem-plugin.module'
import { WeatherPluginModule } from './plugins/weather/weather-plugin.module'

@Module({
  imports: [
    DiscoveryModule,
    DatabasePluginModule,
    FileSystemPluginModule,
    WeatherPluginModule
  ],
  controllers: [MCPController],
  providers: [MCPDiscoveryService]
})
export class AppModule {}

Testing is easy

NestJS makes testing straightforward:

import { Test, TestingModule } from '@nestjs/testing'
import { DatabasePlugin } from './database.plugin'
import { DatabaseService } from './database.service'

describe('DatabasePlugin', () => {
  let plugin: DatabasePlugin
  let databaseService: DatabaseService

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        DatabasePlugin,
        {
          provide: DatabaseService,
          useValue: {
            query: jest.fn(),
            getSchema: jest.fn()
          }
        }
      ]
    }).compile()

    plugin = module.get<DatabasePlugin>(DatabasePlugin)
    databaseService = module.get<DatabaseService>(DatabaseService)
  })

  describe('queryDatabase', () => {
    it('should execute query and return results', async () => {
      const mockResults = [{ id: 1, name: 'Test' }]
      jest.spyOn(databaseService, 'query').mockResolvedValue(mockResults)

      const result = await plugin.queryDatabase({
        query: 'SELECT * FROM users',
        params: []
      })

      expect(result).toEqual({
        rows: mockResults,
        count: 1
      })
      expect(databaseService.query).toHaveBeenCalledWith(
        'SELECT * FROM users',
        []
      )
    })
  })

  describe('getDatabaseSchema', () => {
    it('should return schema as JSON string', async () => {
      const mockSchema = { tables: ['users', 'posts'] }
      jest.spyOn(databaseService, 'getSchema').mockResolvedValue(mockSchema)

      const result = await plugin.getDatabaseSchema()

      expect(result).toBe(JSON.stringify(mockSchema, null, 2))
    })
  })
})

Load plugins dynamically

Want to load plugins at runtime? Here’s how:

import { Injectable, OnModuleInit } from '@nestjs/common'
import { ModuleRef } from '@nestjs/core'

@Injectable()
export class DynamicPluginLoader implements OnModuleInit {
  constructor(private readonly moduleRef: ModuleRef) {}

  async onModuleInit() {
    const pluginModules = await this.discoverPluginModules()

    for (const pluginModule of pluginModules) {
      await this.loadPluginModule(pluginModule)
    }
  }

  private async discoverPluginModules(): Promise<string[]> {
    // Discover plugin modules from configuration or file system
    return process.env.MCP_PLUGINS?.split(',') || []
  }

  private async loadPluginModule(modulePath: string) {
    try {
      const module = await import(modulePath)
      // Register module dynamically
      console.log(`Loaded plugin module: ${modulePath}`)
    } catch (error) {
      console.error(`Failed to load plugin module ${modulePath}:`, error)
    }
  }
}

Configuration

Use NestJS config module for settings:

import { Module } from '@nestjs/common'
import { ConfigModule, ConfigService } from '@nestjs/config'

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      envFilePath: '.env'
    }),
    DatabasePluginModule.forRootAsync({
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        connectionString: config.get('DATABASE_URL')
      })
    }),
    WeatherPluginModule.forRootAsync({
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        apiKey: config.get('WEATHER_API_KEY')
      })
    })
  ]
})
export class AppModule {}

What I learned using NestJS for MCP

Use dependency injection: Let NestJS inject your services. Don’t create them manually.

Decorators everywhere: Define all MCP capabilities with decorators. Makes them discoverable.

Organize by feature: Group related tools, resources, and prompts into modules.

Test everything: Use NestJS testing utilities. They’re really good.

Handle errors properly: Use exception filters for consistent error handling.

Write good descriptions: The AI needs clear descriptions to use your capabilities.

Version your APIs: Use NestJS versioning for breaking changes.

Monitor performance: Use interceptors to track how your plugins perform.

Wrapping up

NestJS is perfect for MCP servers. Dependency injection, decorators, and modules make everything cleaner. You get enterprise features without the enterprise complexity.

Start with a couple simple plugin modules. Expand as you need to. The patterns here scale from hobby projects to production systems.

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