Cross-posted from my blog. The original, with any updates, lives on fabwaseem.com.

To build an MCP server in NestJS, install the official MCP TypeScript SDK (@modelcontextprotocol/server and @modelcontextprotocol/node), build one createMcpHandler inside an injectable service, and call it from a @Post('mcp') controller method that takes @Req() and @Res(). Put a Nest guard in front for the bearer token and Origin check, pass req.body to the handler, and your tools can use any provider through normal dependency injection.

I built exactly that on November 4, 2026 and tested it end to end: with curl, the MCP Inspector CLI, and 8 e2e tests that use the official MCP client against both protocol versions. Below is the full code, how the pieces fit, and five problems I hit that most tutorials skip, including one that turns every request into a Parse error.

Is there an official NestJS MCP module?

No. There is no @nestjs/mcp package: the npm registry returned 404 for it on November 4, 2026. You have two real options:

  • Wire the official SDK yourself, which is what this guide does. It came to about 180 lines across a provider, a service, a guard, a controller and two modules, and you control auth, scopes and errors.
  • Use the community module @rekog/mcp-nest (MIT, version 2.0.9, about 384,000 npm downloads in the week of October 27 to November 2, 2026). Its README describes it as a NestJS microservice transport strategy: tools are @Tool() methods on @McpController() classes, so Nest guards, pipes, interceptors and filters apply, and it serves Streamable HTTP and STDIO. It peer-depends on the same v2 SDK packages and isn't maintained by the NestJS team.

Hand-wiring is worth understanding even if you end up on the module, because the gotchas below come from how Nest and the SDK share one Express request.

What I tested

Everything ran on one Linux VM (8 vCPUs) on Node.js 24.22.1:

  • NestJS 12.3.2 with @nestjs/platform-express (Express 5.3.0)
  • @modelcontextprotocol/server 2.4.0 and @modelcontextprotocol/node 2.1.2, the v2 SDK
  • @modelcontextprotocol/client 2.4.0 for the e2e tests, MCP Inspector 2.13.0 for manual calls
  • Zod 4.6.7, TypeScript 6.0.3, tests on node:test

The demo is a small notes API: a NotesService provider, a search_notes tool any token can call, and a create_note tool only a write-scoped token can see. If you're new to the SDK itself, my guide on building an MCP server in Next.js covers tools, schemas and the v1 to v2 changes in more depth.

Step 1: Install the packages

npm install @nestjs/common @nestjs/core @nestjs/platform-express reflect-metadata rxjs \ @modelcontextprotocol/server @modelcontextprotocol/node zod npm install -D typescript @types/node @types/express @nestjs/testing @modelcontextprotocol/client 

NestJS 12 ships as ES modules ("type": "module" in @nestjs/core), so set "type": "module" in your package.json and use .js extensions in relative imports. The tsconfig.json I used:

{ "compilerOptions": { "module": "nodenext", "moduleResolution": "nodenext", "target": "ES2023", "experimentalDecorators": true, "emitDecoratorMetadata": true, "strict": true, "strictPropertyInitialization": false, "skipLibCheck": true, "outDir": "dist", "rootDir": "." }, "include": ["src", "test"] } 

Step 2: A normal Nest provider for your data

The tools should call the same services your REST controllers already use. Here an in-memory store stands in for a Prisma or TypeORM repository:

// src/notes/notes.service.ts import { Injectable } from "@nestjs/common"; export interface Note { id: number; title: string; body: string; owner: string; createdAt: string } @Injectable() export class NotesService { private notes: Note[] = []; private nextId = 1; search(owner: string, q?: string): Note[] { const needle = q?.toLowerCase(); return this.notes.filter( (n) => n.owner === owner && (!needle || `${n.title} ${n.body}`.toLowerCase().includes(needle)), ); } create(owner: string, title: string, body: string): Note { const note = { id: this.nextId++, title, body, owner, createdAt: new Date().toISOString() }; this.notes.push(note); return note; } } 

Export it from a NotesModule (providers: [NotesService], exports: [NotesService]) like any shared provider.

Step 3: The MCP service (handler, tools and DI)

This is the core. createMcpHandler takes a factory that returns a fresh McpServer, and the SDK's HTTP guide says the factory runs once per HTTP request and receives the request context, including authInfo. That gives you two things in Nest:

  1. The factory is an arrow function inside an @Injectable() class, so every tool handler can use this.notes or any other injected provider.
  2. You can register a different set of tools per caller. A token without the write scope never sees create_note in tools/list.
// src/mcp/mcp.service.ts import { Injectable, Logger, OnModuleDestroy } from "@nestjs/common"; import { createMcpHandler, McpServer, type McpRequestContext } from "@modelcontextprotocol/server"; import { toNodeHandler } from "@modelcontextprotocol/node"; import * as z from "zod/v4"; import { NotesService } from "../notes/notes.service.js"; @Injectable() export class McpService implements OnModuleDestroy { private readonly logger = new Logger(McpService.name); // Built once. The factory runs once per HTTP request, so every request gets a fresh McpServer. private readonly handler = createMcpHandler((ctx) => this.createServer(ctx), { responseMode: "json", onerror: (err) => this.logger.warn(err.message), }); readonly node = toNodeHandler(this.handler); constructor(private readonly notes: NotesService) {} private createServer({ authInfo }: McpRequestContext): McpServer { const owner = authInfo?.clientId ?? "anonymous"; const scopes = authInfo?.scopes ?? []; const server = new McpServer({ name: "notes", version: "1.0.0" }); server.registerTool( "search_notes", { title: "Search notes", description: "Search the caller's notes by a word in the title or body. Returns at most 20.", inputSchema: z.object({ q: z.string().max(200).optional().describe("Text to match; omit to list all") }), annotations: { readOnlyHint: true }, }, async ({ q }) => { const hits = this.notes.search(owner, q).slice(0, 20); return { content: [{ type: "text", text: JSON.stringify(hits) }] }; }, ); // Only tokens with the write scope ever see this tool in tools/list. if (scopes.includes("notes:write")) { server.registerTool( "create_note", { title: "Create note", description: "Save a new note for the caller.", inputSchema: z.object({ title: z.string().min(1).max(120), body: z.string().max(5000) }), outputSchema: z.object({ id: z.number(), title: z.string() }), }, async ({ title, body }) => { const note = this.notes.create(owner, title, body); const out = { id: note.id, title: note.title }; return { content: [{ type: "text", text: JSON.stringify(out) }], structuredContent: out }; }, ); } return server; } async onModuleDestroy() { await this.handler.close(); } } 

Notes on the choices:

  • toNodeHandler from @modelcontextprotocol/node adapts the fetch-style handler to Node's (req, res, parsedBody), which is what Express (and so Nest) gives you.
  • responseMode: "json" answers modern requests with one JSON body. The SDK prints a console warning when you set it, because progress and log notifications sent before the result are dropped. Leave it out if your tools stream progress.
  • handler.close() in onModuleDestroy aborts in-flight requests when Nest shuts down. Call app.enableShutdownHooks() so the hook runs on SIGTERM.
  • The owner comes from authInfo, not from tool arguments. The model can't ask for someone else's notes by passing a different user ID.

Step 4: A guard for the bearer token and Origin

The SDK checks no tokens and no Origin header on its own: authInfo is pass-through. A Nest guard is the natural place for both. The MCP transport spec says servers must validate Origin to stop DNS rebinding, and the SDK exports validateOriginHeader for that.

// src/mcp/mcp-auth.guard.ts import { CanActivate, ExecutionContext, ForbiddenException, Injectable, UnauthorizedException } from "@nestjs/common"; import { createHash, timingSafeEqual } from "node:crypto"; import { validateOriginHeader, type AuthInfo } from "@modelcontextprotocol/server"; import type { Request, Response } from "express"; const sha = (s: string) => createHash("sha256").update(s).digest(); const same = (a: string, b: string) => timingSafeEqual(sha(a), sha(b)); // token -> who it belongs to and what it may do. Load from env or a DB in a real app. function tokenTable(): Array<{ token: string; clientId: string; scopes: string[] }> { return [ { token: process.env.MCP_READ_TOKEN ?? "", clientId: "reader", scopes: ["notes:read"] }, { token: process.env.MCP_WRITE_TOKEN ?? "", clientId: "writer", scopes: ["notes:read", "notes:write"] }, ].filter((t) => t.token.length >= 32); } const ALLOWED_ORIGINS = (process.env.MCP_ALLOWED_ORIGINS ?? "localhost,127.0.0.1").split(","); @Injectable() export class McpAuthGuard implements CanActivate { canActivate(ctx: ExecutionContext): boolean { const http = ctx.switchToHttp(); const req = http.getRequest<Request & { auth?: AuthInfo }>(); // DNS rebinding / CSRF: a browser Origin must be on the list. No Origin (CLI, agents) passes. const origin = validateOriginHeader(req.headers.origin, ALLOWED_ORIGINS); if (!origin.ok) throw new ForbiddenException(origin.message); const m = /^Bearer\s+(\S+)$/i.exec(req.headers.authorization ?? ""); const hit = m && tokenTable().find((t) => same(m[1], t.token)); if (!hit) { http.getResponse<Response>().setHeader("WWW-Authenticate", 'Bearer realm="mcp"'); throw new UnauthorizedException("Invalid or missing bearer token"); } req.auth = { token: m[1], clientId: hit.clientId, scopes: hit.scopes }; return true; } } 

The guard sets req.auth, and toNodeHandler reads exactly that property and hands it to your factory as authInfo. That one line is the bridge between Nest's auth and the MCP server. Hashing both tokens before timingSafeEqual keeps the comparison constant-time even when lengths differ, and tokens shorter than 32 characters are ignored so an empty env var can't open the endpoint. For OAuth instead of static tokens, see the MCP authorization spec and my notes on MCP security.

Step 5: The controller

// src/mcp/mcp.controller.ts import { All, Controller, Header, HttpCode, Post, Req, Res, UseGuards } from "@nestjs/common"; import type { Request, Response } from "express"; import { McpAuthGuard } from "./mcp-auth.guard.js"; import { McpService } from "./mcp.service.js"; @Controller("mcp") export class McpController { constructor(private readonly mcp: McpService) {} @Post() @UseGuards(McpAuthGuard) async handle(@Req() req: Request, @Res() res: Response): Promise<void> { // With @Res() the SDK writes the response itself, so set headers on res directly. res.setHeader("Cache-Control", "no-store"); // Pass req.body: Nest's JSON parser has already read the stream. await this.mcp.node(req, res, req.body); } // Stateless server: no GET stream, no DELETE session. @All() @HttpCode(405) @Header("Allow", "POST") notAllowed() { return { error: "Method not allowed" }; } } 

Then the module and main.ts:

// src/mcp/mcp.module.ts import { Module } from "@nestjs/common"; import { NotesModule } from "../notes/notes.module.js"; import { McpController } from "./mcp.controller.js"; import { McpService } from "./mcp.service.js"; @Module({ imports: [NotesModule], controllers: [McpController], providers: [McpService] }) export class McpModule {} 
// src/main.ts import "reflect-metadata"; import { NestFactory } from "@nestjs/core"; import type { NestExpressApplication } from "@nestjs/platform-express"; import { AppModule } from "./app.module.js"; const app = await NestFactory.create<NestExpressApplication>(AppModule); // Express's JSON parser defaults to 100kb; the MCP SDK itself allows 4 MiB. app.useBodyParser("json", { limit: "4mb" }); app.enableShutdownHooks(); await app.listen(Number(process.env.PORT ?? 3000), "127.0.0.1"); console.log(`MCP endpoint: ${await app.getUrl()}/mcp`); 

AppModule just imports McpModule. Build with tsc, start with two tokens, and the endpoint is http://127.0.0.1:3000/mcp.

Step 6: Test it (curl, Inspector, e2e)

First the cheap checks with curl. Against my running server:

Request Result
POST /mcp with no token 401, WWW-Authenticate: Bearer realm="mcp"
POST /mcp with Origin: https://evil.example 403 Invalid Origin: evil.example
GET /mcp 405
tools/list with the read token search_notes only
tools/list with the write token search_notes, create_note

Then a real tool call with the MCP Inspector CLI. It takes the bearer token through --header:

npx @modelcontextprotocol/inspector --cli http://127.0.0.1:3000/mcp \ --transport http --header "Authorization: Bearer $MCP_WRITE_TOKEN" \ --method tools/call --tool-name create_note --tool-arg title=Hello --tool-arg body=From-inspector 

It printed "structuredContent": { "id": 1, "title": "Hello" }.

For CI, boot the real Nest app on a...