How to Transform an API to an MCP Server with NestJS
Introduction
At ADEO, we’ve been exploring the Model Context Protocol (MCP) to enable AI assistants to interact with our backend systems. However, we faced a critical architectural decision: should we make our production APIs MCP-compliant, or should we create separate MCP servers?
The Security Imperative
For security reasons, we decided against exposing our production APIs directly as MCP servers. Here’s why:
MCP is a brand new protocol — at the time of writing, it’s still in its early days. We don’t fully understand its vulnerabilities, attack vectors, or long-term security implications. If we make our production API instances MCP-compliant and a critical security issue is discovered:
- We would need to shut down the MCP functionality immediately
- This would also shut down our production API
- Business operations would be disrupted
- Production services would fail
By separating the MCP server from the production API, we can shut down the MCP instance without any impact on business-critical operations.
Alternative Approaches and Their Limitations
One might suggest: “Why not create an MCP server that simply consumes your API as a regular HTTP client?” This approach has merit, but introduces significant challenges:
-
API Gateway Complexity: At ADEO, like many enterprises, our APIs sit behind sophisticated API Gateways. An MCP server calling our API would need to:
- Traverse the entire gateway infrastructure
- Handle gateway authentication and authorization
- Deal with rate limiting and throttling
- Accept additional network latency (gateway processing + API processing)
-
Protocol Incompatibility: By design, most business APIs are not built for streaming protocols. The MCP protocol is designed around:
- Server-Sent Events (SSE) for bidirectional communication
- Streamable HTTP for long-running operations
- Real-time data streaming for AI assistant interactions
Traditional REST APIs don’t support these patterns natively. Adapting them would require significant refactoring.
-
Authentication Separation: We want different authentication mechanisms for:
- Production API: Enterprise OAuth2, API keys, service accounts
- MCP Server: AI assistant-specific authentication, potentially different security requirements
-
Observability: Separate instances allow us to:
- Monitor MCP-specific metrics independently
- Track AI assistant usage patterns
- Debug issues without affecting production monitoring
- Apply different logging and telemetry strategies
The Challenge: Avoiding Code Duplication
While we need separate deployments, we don’t want to duplicate our business logic. Writing the same service layer twice (once for the API, once for MCP) would be a maintenance nightmare.
The question becomes: Can we use NestJS to maintain a single source of truth for our business logic while deploying two separate applications (API and MCP server)?
The answer is yes, and this article documents my study on achieving this goal.
Architecture Overview
NestJS Monorepo: The Foundation
NestJS provides excellent monorepo support that allows multiple applications to share code within a single workspace. Our architecture leverages this to create:
nestjs-api-mcp/
├── apps/
│ ├── api/ # Traditional REST API (production)
│ └── mcp/ # MCP Server (AI assistant gateway)
├── libs/ # Shared libraries (optional)
├── prisma/ # Database schema
└── generated/ # Auto-generated types (Prisma, DTOs)
Both applications share:
- Business logic (
app.service.tsimported via path aliases) - Database models (Prisma types)
- DTOs (Data Transfer Objects)
- Utilities and helper functions
Key Design Principles
-
Separation of Concerns:
- API handles traditional HTTP REST endpoints
- MCP handles AI assistant communication via SSE/streaming
-
Code Reusability:
- Business logic lives in shared services
- Both apps import the same service layer
- No duplication of database access or domain logic
-
Independent Deployment:
- Each app can be built, tested, and deployed separately
- Different security configurations
- Different scaling strategies
-
Type Safety:
- Shared TypeScript types across apps
- Prisma-generated types available to both
- Full IDE support and autocomplete
The MCP Server Implementation
1. Tool Discovery with Decorators
One of the biggest challenges was exposing service methods as MCP tools without manual registration. I created a TypeScript decorator that automatically discovers and registers tools at application startup.
The @McpTool Decorator
// apps/mcp/src/decorators/mcp-tool.decorator.ts
import 'reflect-metadata';
import type { AnySchema } from '@modelcontextprotocol/sdk/server/zod-compat.js';
export const MCP_TOOL_METADATA = 'mcp:tool';
export interface McpToolMetadata {
name: string;
title?: string;
description?: string;
inputSchema?: AnySchema;
methodName: string;
paramMap?: Record<string, string>;
}
export interface McpToolOptions {
name: string;
title?: string;
description?: string;
inputSchema?: AnySchema;
/**
* Maps MCP input schema property names to method parameter names.
* Example: { filepath: 'filePath', userid: 'userId' }
*/
paramMap?: Record<string, string>;
}
export function McpTool(options: McpToolOptions) {
return (
target: any,
propertyKey: string,
descriptor: PropertyDescriptor,
): PropertyDescriptor => {
const metadata: McpToolMetadata = {
...options,
methodName: propertyKey,
};
// Store metadata using reflect-metadata
Reflect.defineMetadata(MCP_TOOL_METADATA, metadata, target, propertyKey);
return descriptor;
};
}
How it works:
- The decorator stores metadata about each method using TypeScript’s
reflect-metadata - The metadata includes the tool name, description, input schema (Zod), and parameter mapping
- At application startup, NestJS’s
DiscoveryServicescans all providers for decorated methods - Each decorated method is automatically registered as an MCP tool
2. Exposing Shared Services as MCP Tools
Here’s how we expose methods from our shared AppService as MCP tools:
// apps/mcp/src/api/api.service.ts
import { Injectable } from '@nestjs/common';
import { AppService } from '@api/app.service'; // Shared service via path alias
import { McpTool } from '../decorators';
import * as z from 'zod/v4';
import { Observable } from 'rxjs';
@Injectable()
export class ApiService {
constructor(private readonly appService: AppService) {}
@McpTool({
name: 'getHello',
title: 'Get Hello Message',
description: 'Get hello message',
})
getHello(): string {
return this.appService.getHello();
}
@McpTool({
name: 'getAllUsers',
title: 'Get All Users',
description: 'Get all users from the database',
})
getAllUsers(): Observable<any> {
return this.appService.getAllUsers();
}
@McpTool({
name: 'getPostById',
title: 'Get Post By Id',
description: 'Get a specific post by its ID',
inputSchema: z.object({
id: z.number().describe('The post ID'),
}),
})
getPostById(id: number): Observable<any> {
return this.appService.getPostById(id);
}
}
Key observations:
- We inject the shared
AppServicefrom the API app - Each method is decorated with
@McpToolto expose it as an MCP tool - Input validation is defined with Zod schemas
- Methods can return Observables for streaming data
- No business logic duplication — we’re just wrapping existing methods
3. Automatic Tool Registration
The McpService uses NestJS’s DiscoveryService to automatically find and register all decorated methods:
// apps/mcp/src/mcp.service.ts (simplified)
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from
'@modelcontextprotocol/sdk/server/streamableHttp.js';
import { DiscoveryService } from '@nestjs/core';
import { InstanceWrapper } from '@nestjs/core/injector/instance-wrapper';
import { MCP_TOOL_METADATA, type McpToolMetadata } from './decorators';
import { isObservable, lastValueFrom } from 'rxjs';
import { tap } from 'rxjs/operators';
@Injectable()
export class McpService implements OnApplicationBootstrap {
private readonly logger = new Logger(McpService.name);
private server: McpServer;
public transport: StreamableHTTPServerTransport;
constructor(private readonly discoveryService: DiscoveryService) {
this.initializeMcpServer();
}
private initializeMcpServer(): void {
this.server = new McpServer({
name: 'nestjs-api-mcp',
version: '1.0.0',
});
this.transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
}
async onApplicationBootstrap(): Promise<void> {
await this.discoverAndRegisterTools();
await this.server.connect(this.transport);
this.logger.log('MCP Service initialized successfully');
}
private async discoverAndRegisterTools(): Promise<void> {
const providers: InstanceWrapper[] = this.discoveryService.getProviders();
let toolCount = 0;
for (const wrapper of providers) {
if (!wrapper.instance || !wrapper.metatype) continue;
const instance = wrapper.instance;
const prototype = Object.getPrototypeOf(instance);
const methodNames = Object.getOwnPropertyNames(prototype)
.filter(name => name !== 'constructor');
const toolMetadata: McpToolMetadata[] = [];
for (const methodName of methodNames) {
const metadata: McpToolMetadata | undefined = Reflect.getMetadata(
MCP_TOOL_METADATA,
prototype,
methodName,
);
if (metadata) {
toolMetadata.push(metadata);
}
}
if (toolMetadata.length === 0) continue;
this.logger.log(
`Found ${toolMetadata.length} MCP tools in ${wrapper.metatype.name}`,
);
for (const tool of toolMetadata) {
const methodRef = instance[tool.methodName];
if (typeof methodRef === 'function') {
this.server.registerTool(
tool.name,
{
title: tool.title,
description: tool.description,
inputSchema: tool.inputSchema,
},
async (args: unknown) => {
try {
// Transform MCP args to method args
const methodArgs = this.transformArgs(args, tool);
const result = await methodRef.call(instance, methodArgs);
// Handle Observable results by streaming via MCP logs
if (isObservable(result)) {
let emissionCount = 0;
const finalResult = await lastValueFrom(
result.pipe(
tap(async (value) => {
emissionCount++;
await this.sendLog('info', `tool:${tool.name}`, {
emission: emissionCount,
data: value,
});
}),
),
);
return this.formatToolResult(finalResult);
}
return this.formatToolResult(result);
} catch (error) {
this.logger.error(`Error executing tool ${tool.name}:`, error);
return {
content: [{
type: 'text' as const,
text: `Error: ${error.message}`,
}],
};
}
},
);
toolCount++;
}
}
}
this.logger.log(`Registered ${toolCount} MCP tools total`);
}
private transformArgs(args: unknown, tool: McpToolMetadata): unknown {
if (!args || (typeof args === 'object' && Object.keys(args).length === 0)) {
return undefined;
}
// Extract single parameter
if (!tool.paramMap && typeof args === 'object') {
const keys = Object.keys(args as Record<string, unknown>);
if (keys.length === 1) {
return (args as Record<string, unknown>)[keys[0]];
}
return args;
}
// Apply paramMap transformation if provided
if (tool.paramMap) {
const transformed: Record<string, unknown> = {};
for (const [mcpParam, methodParam] of Object.entries(tool.paramMap)) {
transformed[methodParam] = (args as Record<string, unknown>)[mcpParam];
}
return transformed;
}
return args;
}
private formatToolResult(result: unknown): CallToolResult {
// If already in MCP format, return as-is
if (
result &&
typeof result === 'object' &&
'content' in result &&
Array.isArray((result as any).content)
) {
return result as CallToolResult;
}
// Otherwise, wrap in MCP format
return {
content: [
{
type: 'text' as const,
text: JSON.stringify(result, null, 2),
},
],
};
}
public async sendLog(level: string, logger: string, data: any): Promise<void> {
await this.server.sendLoggingMessage({ level, logger, data });
}
}
What’s happening here:
- Discovery Phase: At application startup,
DiscoveryServicescans all providers - Metadata Extraction: For each provider, we check methods for
MCP_TOOL_METADATA - Tool Registration: Each decorated method is registered with the MCP server
- Observable Handling: If a method returns an RxJS Observable, emissions are streamed via MCP logging
- Automatic Formatting: Results are automatically formatted to match MCP’s
CallToolResultformat
4. HTTP Controller for MCP Protocol
The MCP server needs an HTTP endpoint to handle streaming requests:
// apps/mcp/src/mcp.controller.ts
import { Controller, Post, Req, Res, Logger } from '@nestjs/common';
import { IncomingMessage, ServerResponse } from 'node:http';
import { McpService } from './mcp.service';
@Controller('mcp')
export class McpController {
private readonly logger = new Logger(McpController.name);
constructor(private readonly mcpService: McpService) {}
@Post('')
async postMcpEndpoint(
@Req() request: IncomingMessage & { body?: any },
@Res() response: ServerResponse<IncomingMessage>,
) {
const method = request.body?.method || 'unknown';
this.logger.log(`Incoming MCP request: ${method}`);
// Lifecycle observability
response.on('finish', () => {
this.logger.log(`Response finished for ${method} (${response.statusCode})`);
});
response.on('close', () => {
this.logger.log(`Connection closed for ${method}`);
});
try {
// Transport takes exclusive control for SSE streaming
await this.mcpService.transport.handleRequest(
request,
response,
request.body,
);
} catch (error) {
this.logger.error(`Error handling MCP request:`, error);
if (!response.headersSent) {
response.writeHead(500, { 'Content-Type': 'application/json' });
response.end(JSON.stringify({
jsonrpc: '2.0',
error: { code: -32603, message: 'Internal error' },
}));
}
}
}
}
Important notes:
- The transport’s
handleRequestmethod takes exclusive control of the response - You cannot write to the response after
handleRequestis called - Use
response.on('finish')andresponse.on('close')for lifecycle logging - Check
response.headersSentbefore sending error responses
Sharing Code Across Applications
Path Aliases and Monorepo Configuration
To enable code sharing, we configure TypeScript path aliases:
// tsconfig.json
{
"compilerOptions": {
"baseUrl": "./",
"paths": {
"@api/*": ["apps/api/src/*"],
"@prisma-generated/*": ["generated/prisma/*"]
}
}
}
This allows the MCP app to import from the API app:
import { AppService } from '@api/app.service';
import { User, Post } from '@prisma-generated/client';
Nest CLI Monorepo Configuration
// nest-cli.json
{
"monorepo": true,
"root": "apps/api",
"projects": {
"api": {
"type": "application",
"root": "apps/api",
"entryFile": "main",
"sourceRoot": "apps/api/src"
},
"mcp": {
"type": "application",
"root": "apps/mcp",
"entryFile": "main",
"sourceRoot": "apps/mcp/src"
}
}
}
This enables:
npm run start:dev api— Start the REST APInpm run start:dev mcp— Start the MCP server- Both apps built and deployed independently
Shared Business Logic Example
// apps/api/src/app.service.ts (shared service)
import { Injectable } from '@nestjs/common';
import { from, Observable } from 'rxjs';
import { PrismaService } from './prisma/prisma.service';
import { User, Post } from '@prisma-generated/client';
@Injectable()
export class AppService {
constructor(private readonly prisma: PrismaService) {}
getAllUsers(): Observable<(User & { posts: Post[] })[]> {
return from(
this.prisma.user.findMany({
include: { posts: true },
})
);
}
getPostById(id: number): Observable<(Post & { author: User }) | null> {
return from(
this.prisma.post.findUnique({
where: { id },
include: { author: true },
})
);
}
}
Both applications use the exact same service — no duplication!
MCP SDK Version Strategy
Why I Stayed on v1.24.3
During my study, I discovered that the MCP SDK underwent significant architectural changes between versions. I decided to stay on v1.24.3 instead of upgrading to v1.26.0+. Here’s why:
v1.24.3 Pattern (Singleton)
@Injectable()
export class McpService {
private server: McpServer; // Created once
public transport: StreamableHTTPServerTransport; // Created once
constructor() {
this.server = new McpServer({ ... });
this.transport = new StreamableHTTPServerTransport({ ... });
}
async onApplicationBootstrap() {
this.discoverAndRegisterTools(); // Register tools once
await this.server.connect(this.transport); // Connect once
}
}
Characteristics:
- One
McpServerinstance for the entire application lifecycle - One transport instance for all requests
- Tools registered once during bootstrap
server.connect()called once- Minimal per-request overhead
v1.26.0 Pattern (Per-Request)
app.post('/mcp', async (req, res) => {
const server = getServer(); // Create NEW server
const transport = new StreamableHTTPServerTransport({ ... });
await server.connect(transport); // Connect for THIS request
await transport.handleRequest(req, res, req.body);
res.on('close', () => {
transport.close();
server.close();
});
});
Characteristics:
- New
McpServercreated for every request - New transport created for every request
- All tools registered for every request
server.connect()called before each request- Significant per-request overhead
Performance Comparison
| Operation | v1.24.3 | v1.26.0 | Impact |
|---|---|---|---|
| Create McpServer | Once (startup) | Every request | High |
| Register tools | Once (4 tools at startup) | Every request (4 tools × N requests) | High |
| Create transport | Once (startup) | Every request | Medium |
| Connect server | Once (startup) | Every request | Medium |
| Cleanup | Never (GC at shutdown) | Every request | Low |
Estimated overhead: 5-10ms additional latency per request in v1.26.0
Why v1.24.3 Works Better for NestJS
- Singleton Pattern: NestJS services are singletons by design — v1.24.3 aligns perfectly
- Performance: No per-request overhead for server/transport creation
- Simplicity: Less code complexity, easier to maintain
- Stability: Proven stable in production, no breaking issues
For our stateless use case, v1.26.0 provides no benefits while introducing overhead and complexity.
When to Reconsider
We would upgrade to v1.26.0+ if:
- Critical security vulnerabilities are found in v1.24.3
- New MCP protocol features are required that only exist in newer versions
- The SDK team provides official guidance for long-lived server instances
- A stateful mode becomes necessary for our use case
Developer Experience Highlights
1. Type Safety Everywhere
// Shared Prisma types
import { User, Post } from '@prisma-generated/client';
// Auto-generated DTOs from Swagger
import { CreateUserDto } from '@api/users/dto/create-user.dto';
// Full TypeScript validation
@McpTool({
inputSchema: z.object({
id: z.number().positive(),
}),
})
getPostById(id: number): Observable<Post | null> {
// Full type checking and autocomplete
}
2. Single Command Development
# Terminal 1: Run API
npm run start:dev api
# Terminal 2: Run MCP Server
npm run start:dev mcp
# Both watch for changes and hot-reload
3. Automatic Tool Discovery Logs
When the MCP server starts, you see:
[McpService] Found 4 MCP tools in ApiService
[McpService] → Registering tool: getHello (getHello)
[McpService] → Registering tool: getAllUsers (getAllUsers)
[McpService] → Registering tool: getAllPosts (getAllPosts)
[McpService] → Registering tool: getPostById (getPostById)
[McpService] Registered 4 MCP tools total
[McpService] MCP Service initialized successfully
No manual registration required!
4. Observable Streaming Support
Methods that return RxJS Observables automatically stream their emissions:
@McpTool({ name: 'getAllUsers' })
getAllUsers(): Observable<User[]> {
return this.appService.getAllUsers();
}
// MCP logs show:
// tool:getAllUsers - Streaming data emission 1
// tool:getAllUsers - Streaming data emission 2
// tool:getAllUsers - Stream complete. Total emissions: 2
Security Considerations
Separate Authentication Systems
// API (apps/api/src/auth/jwt-auth.guard.ts)
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
// Enterprise OAuth2, API keys, etc.
}
// MCP (apps/mcp/src/auth/mcp-auth.guard.ts)
@Injectable()
export class McpAuthGuard implements CanActivate {
// AI assistant-specific authentication
async canActivate(context: ExecutionContext): Promise<boolean> {
// Custom MCP authentication logic
}
}
Separate Deployment Units
# docker-compose.yml (example)
services:
api:
build: ./apps/api
ports:
- "3000:3000"
environment:
- DATABASE_URL=${DATABASE_URL}
- AUTH_TYPE=jwt
mcp:
build: ./apps/mcp
ports:
- "3001:3001"
environment:
- DATABASE_URL=${DATABASE_URL}
- AUTH_TYPE=mcp-custom
Key benefits:
- MCP server can be shut down independently
- Different security policies and configurations
- Separate monitoring and observability
- Different scaling strategies
Real-World Usage
Starting the Applications
# Install dependencies
npm install
# Generate Prisma types
npm run prisma:generate
# Start the API (port 3000)
npm run start:dev api
# Start the MCP server (port 3001)
npm run start:dev mcp
Testing with an MCP Client
You can test the MCP server with any MCP-compatible client (Claude Desktop, custom clients, etc.):
{
"mcpServers": {
"nestjs-api": {
"url": "http://localhost:3001/mcp"
}
}
}
Example Tool Call
When an AI assistant calls getPostById:
{
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": "getPostById",
"arguments": {
"id": 1
}
}
}
The MCP server:
- Validates the input against the Zod schema
- Transforms
{ id: 1 }to just1(single parameter extraction) - Calls
appService.getPostById(1) - Streams the Observable emissions via MCP logs
- Returns the final result in MCP format
Lessons Learned
What Worked Well
- Decorator Pattern: The
@McpTooldecorator provides an excellent developer experience - Monorepo Architecture: Sharing code while keeping deployments separate is powerful
- Type Safety: TypeScript + Prisma + Zod = zero runtime surprises
- Observable Streaming: RxJS integration allows real-time data streaming to AI assistants
- Singleton Pattern: v1.24.3’s approach aligns perfectly with NestJS
Challenges
- MCP SDK Evolution: The SDK is still evolving rapidly — version selection matters
- Documentation Gap: MCP SDK documentation for NestJS/singleton patterns is limited
- Streaming Complexity: Understanding SSE lifecycle and
handleRequestbehavior required deep investigation
Best Practices
- Pin MCP SDK version — Don’t auto-upgrade without testing
- Use path aliases — Makes imports clean and maintainable
- Leverage DiscoveryService — Automatic tool registration saves boilerplate
- Separate concerns — Don’t mix API and MCP logic in the same controller
- Test independently — Each app should have its own test suite
Conclusion
By leveraging NestJS’s monorepo capabilities and creating a custom decorator-based tool discovery system, I successfully achieved our goals:
✅ No code duplication between API and MCP server
✅ Excellent developer experience with automatic tool registration
✅ Production-ready security with separate deployment units
✅ Full type safety across shared code
✅ Streaming support via RxJS Observables
✅ Independent scaling and monitoring
The architecture will allow us to:
- Shut down the MCP server without affecting production
- Evolve the MCP implementation without touching the API
- Maintain a single source of truth for business logic
- Deploy updates independently to each application
This approach strikes the perfect balance between security, maintainability, and developer experience.
Resources
- GitHub Repository
- NestJS Monorepo Documentation
- Model Context Protocol
- MCP TypeScript SDK
- NestJS Discovery Module
Questions or feedback? Feel free to open an issue on the GitHub repository or reach out to me on X/Twitter.