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.
