MCP-Plugin-Lösungen mit NestJS erstellen
In den vorherigen Beiträgen haben wir die MCP-Server-Architektur und Plugin-Systeme untersucht. Jetzt sehen wir uns an, wie wir die leistungsstarke Dependency Injection und das Modulsystem von NestJS nutzen können, um produktionsreife MCP-Server mit eleganter Plugin-Architektur zu erstellen. NestJS bietet die perfekte Grundlage für MCP-Server, die testbar, wartbar und skalierbar sind.
Warum NestJS für MCP-Server?
NestJS bringt Enterprise-Grade-Muster in die Node.js-Entwicklung und ist damit ideal für MCP-Server:
- Dependency Injection: Plugin-Abhängigkeiten sauber verwalten ohne manuelle Verdrahtung
- Modulsystem: MCP-Capabilities in kohärente, wiederverwendbare Module organisieren
- Decorators: Tools, Resources und Prompts deklarativ definieren
- Eingebautes Testing: Erstklassige Unterstützung für Unit- und Integrationstests
- Typsicherheit: Vollständige TypeScript-Unterstützung mit starker Typisierung durchgehend
- Lifecycle Hooks: Plugin-Initialisierung und Bereinigung elegant verwalten
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 {}
Projekt-Setup
Beginnen Sie mit einem NestJS-Projekt, das für die MCP-Server-Entwicklung konfiguriert ist:
npm i -g @nestjs/cli
nest new mcp-server
cd mcp-server
npm install @modelcontextprotocol/sdk zod class-validator class-transformer
Konfigurieren Sie TypeScript für Decorators in tsconfig.json:
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"target": "ES2021",
"module": "commonjs",
"strict": true,
"esModuleInterop": true
}
}
MCP-Decorators für NestJS
Erstellen Sie Decorators, die Methoden als MCP-Capabilities markieren:
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
}
}
MCP-Capability-Discovery-Service
Erstellen Sie einen Service, der MCP-Capabilities aus dekorierten Methoden entdeckt:
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)
}
}
Plugin-Module erstellen
Erstellen Sie NestJS-Module, die MCP-Plugin-Funktionalität kapseln:
import { Injectable, Module } from '@nestjs/common'
import { z } from 'zod'
// Datenbank-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 {}
Dateisystem-Plugin-Modul
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 {}
Wetter-API-Plugin-Modul
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 {}
MCP-Server-Controller
Erstellen Sie einen Controller, der MCP-Protokollanfragen verarbeitet:
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
}
}
}
}
}
Haupt-Anwendungsmodul
Verbinden Sie alles im Haupt-Anwendungsmodul:
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 {}
MCP-Plugins testen
NestJS macht das Testen von Plugins unkompliziert:
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))
})
})
})
Dynamisches Plugin-Laden
Aktivieren Sie dynamisches Plugin-Laden zur Laufzeit:
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)
}
}
}
Konfigurationsverwaltung
Verwenden Sie das NestJS-Konfigurationsmodul für Plugin-Einstellungen:
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 {}
Best Practices für NestJS-MCP-Plugins
Dependency Injection nutzen: Verwenden Sie NestJS DI, um Plugin-Abhängigkeiten sauber zu verwalten. Injizieren Sie Services, Konfiguration und andere Abhängigkeiten, anstatt sie manuell zu erstellen.
Decorators konsistent verwenden: Definieren Sie alle MCP-Capabilities mit Decorators. Dies macht Capabilities auffindbar und hält Ihren Code deklarativ.
Nach Features organisieren: Gruppieren Sie verwandte Tools, Resources und Prompts in kohärente Plugin-Module. Jedes Modul sollte eine eigenständige Capability-Domäne repräsentieren.
Gründlich testen: Verwenden Sie NestJS-Test-Utilities, um umfassende Unit- und Integrationstests für Ihre Plugins zu schreiben.
Fehler elegant behandeln: Verwenden Sie NestJS-Exception-Filter, um Fehler konsistent über alle Plugins hinweg zu behandeln.
Capabilities dokumentieren: Bieten Sie klare Beschreibungen für alle Tools, Resources und Prompts. Gute Beschreibungen helfen KI-Modellen, Ihre Capabilities effektiv zu nutzen.
APIs versionieren: Verwenden Sie NestJS-Versionierung, um Breaking Changes in Ihren MCP-Capabilities zu verwalten.
Performance überwachen: Verwenden Sie NestJS-Interceptors, um Plugin-Performance zu protokollieren und zu überwachen.
Fazit
NestJS bietet eine hervorragende Grundlage für den Aufbau produktionsreifer MCP-Server mit Plugin-Architektur. Die Kombination aus Dependency Injection, Decorators und Modulsystem macht es einfach, wartbare, testbare und skalierbare MCP-Server zu erstellen.
Durch die Nutzung von NestJS-Mustern erhalten Sie Enterprise-Grade-Features wie Dependency Injection, Lifecycle-Management und Test-Unterstützung out of the box. Dies ermöglicht es Ihnen, sich auf den Aufbau leistungsstarker MCP-Capabilities zu konzentrieren, anstatt auf Infrastruktur-Belange.
Beginnen Sie mit einigen einfachen Plugin-Modulen und erweitern Sie dann, wenn Ihre Anforderungen wachsen. Die hier gezeigten Muster skalieren von kleinen Projekten bis zu großen Enterprise-Systemen.
Schreiten Sie weiter voran und genießen Sie jeden Schritt Ihrer Programmierreise.
