Guide · HTTP Frameworks
MCP server NestJS — MCP HTTP transport in a NestJS application
NestJS's module system and dependency injection container make it natural to expose an MCP transport as a first-class feature of a larger backend application. This guide shows how to build a reusable McpModule with a forRoot() factory, wire tool services through constructor injection, secure routes with NestJS guards, and use lifecycle hooks for clean transport teardown — with AliveMCP monitoring the MCP protocol layer end-to-end.
TL;DR
Create an McpModule with McpController (POST/GET/DELETE /mcp routes) and McpToolsService (tool registration in onModuleInit). Use OnModuleDestroy to close all transports. Add an McpAuthGuard on /mcp routes and set up AliveMCP to probe the MCP protocol layer — not just HTTP 200 — catching failures that NestJS's DI container silently swallows.
McpModule structure and forRoot() pattern
NestJS encourages encapsulating feature logic in self-contained modules. The McpModule.forRoot() static factory follows the same pattern as NestJS's built-in modules (TypeOrmModule.forRoot(), JwtModule.forRoot()) and lets calling modules pass configuration without coupling to internal implementation details.
// src/mcp/mcp.module.ts
import { Module, DynamicModule } from '@nestjs/common';
import { McpController } from './mcp.controller';
import { McpToolsService } from './mcp-tools.service';
import { McpSessionService } from './mcp-session.service';
import { MCP_OPTIONS } from './mcp.constants';
export interface McpModuleOptions {
serverName: string;
serverVersion: string;
path?: string; // defaults to '/mcp'
}
@Module({})
export class McpModule {
static forRoot(options: McpModuleOptions): DynamicModule {
return {
module: McpModule,
controllers: [McpController],
providers: [
McpSessionService,
McpToolsService,
{
provide: MCP_OPTIONS,
useValue: options,
},
],
exports: [McpToolsService],
};
}
}
// src/mcp/mcp.constants.ts
export const MCP_OPTIONS = Symbol('MCP_OPTIONS');
// src/app.module.ts
import { Module } from '@nestjs/common';
import { McpModule } from './mcp/mcp.module';
@Module({
imports: [
McpModule.forRoot({
serverName: 'my-nestjs-mcp',
serverVersion: '1.0.0',
}),
],
})
export class AppModule {}
Exporting McpToolsService from the module allows other feature modules in the NestJS application to inject the service and register additional tools. This mirrors how NestJS applications typically share infrastructure concerns — the database module exports a repository, the MCP module exports the tool registry.
McpSessionService and McpController with lifecycle hooks
The session store and transport management live in McpSessionService, which is injectable and testable. The controller delegates to the service, keeping route handlers thin. NestJS's OnModuleDestroy hook fires during graceful shutdown, ensuring all transports are closed before the process exits.
// src/mcp/mcp-session.service.ts
import { Injectable, OnModuleDestroy, Inject } from '@nestjs/common';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
import { v4 as uuidv4 } from 'uuid';
import { MCP_OPTIONS, McpModuleOptions } from './mcp.constants';
@Injectable()
export class McpSessionService implements OnModuleDestroy {
private readonly sessions = new Map<string, {
transport: StreamableHTTPServerTransport;
server: McpServer;
}>();
constructor(@Inject(MCP_OPTIONS) private readonly options: McpModuleOptions) {}
async onModuleDestroy() {
const closes = Array.from(this.sessions.values()).map(({ transport }) =>
transport.close()
);
await Promise.allSettled(closes);
this.sessions.clear();
}
getOrCreate(sessionId?: string): StreamableHTTPServerTransport | null {
if (sessionId && this.sessions.has(sessionId)) {
return this.sessions.get(sessionId)!.transport;
}
return null;
}
createSession(toolsService: McpToolsService): {
transport: StreamableHTTPServerTransport;
sessionId: string;
} {
const sessionId = uuidv4();
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => sessionId,
onsessioninitialized: (id) => {
this.sessions.set(id, { transport, server });
},
});
transport.onclose = () => { this.sessions.delete(sessionId); };
const server = new McpServer({
name: this.options.serverName,
version: this.options.serverVersion,
});
toolsService.registerTools(server);
// connect() is async — caller must await
return { transport, sessionId };
}
getTransport(sessionId: string): StreamableHTTPServerTransport | undefined {
return this.sessions.get(sessionId)?.transport;
}
get sessionCount(): number {
return this.sessions.size;
}
}
// src/mcp/mcp.controller.ts
import { Controller, Post, Get, Delete, Req, Res, HttpCode } from '@nestjs/common';
import { Request, Response } from 'express';
import { McpSessionService } from './mcp-session.service';
import { McpToolsService } from './mcp-tools.service';
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
import { UseGuards } from '@nestjs/common';
import { McpAuthGuard } from './mcp-auth.guard';
@Controller('mcp')
@UseGuards(McpAuthGuard)
export class McpController {
constructor(
private readonly sessionService: McpSessionService,
private readonly toolsService: McpToolsService,
) {}
@Post()
async handlePost(@Req() req: Request, @Res() res: Response) {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
let transport = this.sessionService.getOrCreate(sessionId);
if (!transport) {
if (!isInitializeRequest(req.body)) {
res.status(400).json({ error: 'Expected initialize request' });
return;
}
const { transport: newTransport, sessionId: newId } =
this.sessionService.createSession(this.toolsService);
transport = newTransport;
const server = new (await import('@modelcontextprotocol/sdk/server/mcp.js')).McpServer({
name: 'nestjs-mcp', version: '1.0.0',
});
this.toolsService.registerTools(server);
await server.connect(transport);
}
await transport.handleRequest(req, res, req.body);
}
@Get()
async handleGet(@Req() req: Request, @Res() res: Response) {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
const transport = sessionId ? this.sessionService.getTransport(sessionId) : undefined;
if (!transport) { res.status(404).json({ error: 'Unknown session' }); return; }
await transport.handleRequest(req, res);
}
@Delete()
@HttpCode(204)
async handleDelete(@Req() req: Request, @Res() res: Response) {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
const transport = sessionId ? this.sessionService.getTransport(sessionId) : undefined;
if (transport) { await transport.close(); }
res.status(204).end();
}
}
McpToolsService — registering tools with dependency injection
The McpToolsService is where your application's business logic meets the MCP protocol. By injecting NestJS services (repositories, HTTP clients, configuration) into McpToolsService, you keep tool handlers testable and DI-managed without any global state.
// src/mcp/mcp-tools.service.ts
import { Injectable, OnModuleInit } from '@nestjs/common';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { UserService } from '../users/user.service';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class McpToolsService implements OnModuleInit {
// Collect tool definitions before any server is created
private toolDefs: Array<Parameters<McpServer['tool']>> = [];
constructor(
private readonly userService: UserService,
private readonly configService: ConfigService,
) {}
onModuleInit() {
// Register tools during module initialization
this.toolDefs.push([
'get_user',
'Fetch a user by ID from the application database',
{ userId: z.string().uuid() },
async ({ userId }) => {
const user = await this.userService.findById(userId);
if (!user) {
return { content: [{ type: 'text', text: 'User not found' }], isError: true };
}
return { content: [{ type: 'text', text: JSON.stringify(user) }] };
},
]);
this.toolDefs.push([
'server_info',
'Returns server configuration and environment',
{},
async () => ({
content: [{
type: 'text',
text: JSON.stringify({
env: this.configService.get('NODE_ENV'),
version: this.configService.get('APP_VERSION'),
}),
}],
}),
]);
}
registerTools(server: McpServer) {
for (const def of this.toolDefs) {
server.tool(...def);
}
}
}
NestJS's dependency injection means that DI-related misconfiguration (a missing provider, a circular dependency) causes the module to fail at startup — before any MCP sessions are created. However, a misconfigured tool that throws inside its handler will fail silently at the protocol layer: the MCP client receives a JSON-RPC error response, but the HTTP server returns 200. That's exactly the class of failure that AliveMCP catches when it probes the MCP protocol layer rather than just checking HTTP status codes.
McpAuthGuard — securing MCP routes with NestJS guards
NestJS guards implement the CanActivate interface and run before route handlers in the request lifecycle. An McpAuthGuard validates the Authorization header on every MCP request, rejecting unauthenticated sessions before they reach the transport layer. See the full MCP server authentication guide for JWT validation and API key strategies.
// src/mcp/mcp-auth.guard.ts
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { Request } from 'express';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class McpAuthGuard implements CanActivate {
constructor(private readonly configService: ConfigService) {}
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest<Request>();
// Allow the health endpoint through without auth
if (req.path === '/health') return true;
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
throw new UnauthorizedException('Missing or malformed Authorization header');
}
const token = authHeader.slice(7);
const validToken = this.configService.get<string>('MCP_API_KEY');
if (!validToken || token !== validToken) {
throw new UnauthorizedException('Invalid API key');
}
return true;
}
}
// src/health/health.controller.ts
import { Controller, Get } from '@nestjs/common';
import { McpSessionService } from '../mcp/mcp-session.service';
@Controller('health')
export class HealthController {
constructor(private readonly sessionService: McpSessionService) {}
@Get()
check() {
return {
status: 'ok',
sessions: this.sessionService.sessionCount,
uptime: process.uptime(),
ts: Date.now(),
};
}
}
Note that the health endpoint is deliberately excluded from the McpAuthGuard — it needs to be publicly accessible so that AliveMCP can probe it without authentication. If your security posture requires authenticated health checks, AliveMCP supports configuring a static API key or Bearer token on health probes. The guard above handles this by allowing the /health path through unconditionally.
NestJS interceptors are a clean place to add per-tool observability. An interceptor wrapping the MCP controller can log the MCP session ID, the JSON-RPC method, and the response latency to your observability backend — correlating MCP tool calls with the rest of your application's traces. See the rate limiting guide for a NestJS interceptor that enforces per-session tool call quotas.
Application wiring and main.ts bootstrap
The NestJS main.ts bootstraps the application and configures Express-level middleware (CORS, body parsing) before the Nest request pipeline takes over. These must be applied to the underlying Express instance rather than as NestJS middleware, because StreamableHTTPServerTransport reads req.body from Express's parsed body.
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { json } from 'express';
import cors from 'cors';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Apply Express middleware to the underlying HTTP adapter
app.use(cors({
origin: process.env.ALLOWED_ORIGINS?.split(',') ?? '*',
allowedHeaders: ['Content-Type', 'Mcp-Session-Id', 'Authorization'],
exposedHeaders: ['Mcp-Session-Id'],
}));
app.use(json());
// Enable NestJS shutdown hooks for OnModuleDestroy
app.enableShutdownHooks();
const port = Number(process.env.PORT ?? 3000);
await app.listen(port);
console.log(`NestJS MCP server listening on port ${port}`);
}
bootstrap();
The critical line is app.enableShutdownHooks(). Without it, NestJS's OnModuleDestroy lifecycle hooks never fire on SIGTERM — meaning your open MCP transports won't be closed during a rolling deployment. With it enabled, AliveMCP will detect the brief health endpoint outage during the shutdown window and alert you if the new instance doesn't recover within your configured timeout. This gives you a deployment safety net that complements NestJS's own health check module.
For Docker deployments, see the MCP server Docker guide which covers the HEALTHCHECK instruction in combination with STOPSIGNAL SIGTERM and NestJS's shutdown hooks. For multi-tenant applications where different API keys should access different tool sets, the authentication guide covers scoped tool registration based on the authenticated principal.
Frequently asked questions
Why does my NestJS MCP controller get a 404 even though the route is registered?
The most common cause is that the MCP request body hasn't been parsed before the controller runs. NestJS doesn't apply Express's json() middleware automatically — you must call app.use(json()) in main.ts before app.listen(). A secondary cause is that your McpModule is not imported in AppModule, so the McpController is never registered with Nest's routing layer. Check nest-debug output for the list of registered routes to confirm POST /mcp appears.
How do I test McpController and McpToolsService in NestJS unit tests?
Use NestJS's Test.createTestingModule() to create a test module with mock providers substituted for real services. For McpSessionService, provide a mock that returns a fake StreamableHTTPServerTransport. For McpToolsService, inject a mock UserService using { provide: UserService, useValue: mockUserService }. The McpServer and transport can be instantiated in tests without any HTTP server — just call server.connect(transport) with an InMemoryTransport pair from the MCP SDK for unit-level tool call testing.
Can I use NestJS's built-in TerminusModule health checks alongside AliveMCP?
Yes, and the two complement each other. Terminus gives you internal health checks — database connectivity, memory usage, disk space — aggregated at your /health endpoint. AliveMCP monitors that endpoint externally, so it detects failures whether they're infrastructure problems (Terminus reports unhealthy), network issues (the request never arrives), or MCP protocol problems (Terminus reports healthy but the MCP layer is broken). Register a custom Terminus health indicator that checks McpSessionService.sessionCount >= 0 as a basic sanity check that the session service is running.
How do I handle WebSockets alongside MCP HTTP transport in NestJS?
NestJS supports WebSocket gateways via @WebSocketGateway() using either Socket.IO or native WebSocket adapters. Run WebSocket gateways on a different port from the MCP HTTP transport to avoid conflicts between the two protocols' upgrade headers. If you need MCP over WebSocket (not currently in the spec but useful for bidirectional streaming), implement a custom transport adapter that wraps the WebSocket connection in the MCP SDK's transport interface. For most use cases, the HTTP transport with SSE is sufficient and simpler to operate.
Why does AliveMCP need to check the MCP protocol layer and not just HTTP status?
NestJS's DI system can fail to wire a provider correctly — a missing export, a circular dependency resolved at the wrong scope — and the application will start, pass all HTTP health checks, and return 200 on every route, but tool calls will fail with JSON-RPC internal errors. AliveMCP can be configured to send a real MCP initialize request and verify the protocol-level response, catching exactly these silent failures. The health endpoint at /health only tells you the HTTP server is running; it doesn't tell you the MCP session initialization path works end-to-end. Configure protocol-level probing at alivemcp.com in addition to basic HTTP health checks.
Further reading
- MCP server authentication — JWT and API key middleware
- MCP server health checks — designing a robust /health endpoint
- MCP server Docker — containerizing and deploying MCP servers
- MCP server rate limiting — protecting tool call endpoints
- MCP server uptime monitoring — tools and strategies
- MCP server Express — HTTP transport with Express.js