A hello-world MCP server is three functions in a flat file. A NestJS MCP server has to sit next to forty services, a database connection, a JWT guard, and a Pino logger. The difference is dependency injection: tools have to be providers that get their dependencies injected, not closures defined at module scope.
If you haven't built a basic MCP server yet, start with Build an MCP Server from Scratch. This post picks up where that one ends.
MCP tools should be providers in your NestJS module tree, injected like any other class. Each domain registers its own tools; a central registry collects them before the SDK server starts.
src/
mcp/
mcp.module.ts # imports domain modules, boots the MCP server
mcp.service.ts # wraps McpServer, handles transport lifecycle
tool-registry.ts # collects tool definitions from domain modules
orders/
orders.module.ts
orders.tools.ts # registers orders tools into the registry
orders.service.ts
users/
users.module.ts
users.tools.ts
users.service.ts
main.tsDomain modules inject their service into a *Tools class that pushes handlers into ToolRegistry during onModuleInit. McpService iterates the registry and calls server.tool() for each one before connecting the transport. By the time the server is live, every domain has already registered its tools.
There is a community package, @nestjs-mcp/server, that hides this wiring behind decorators. The hand-rolled registry below is about thirty lines and keeps the SDK surface visible, which is worth it while the protocol still changes. It is a plain injectable:
// src/mcp/tool-registry.ts
import { Injectable } from "@nestjs/common";
import { z, ZodRawShape } from "zod";
export type ToolHandler<T extends ZodRawShape> = (
args: z.infer<z.ZodObject<T>>,
) => Promise<{
isError?: boolean;
content: Array<{ type: "text"; text: string }>;
}>;
export interface ToolDefinition {
name: string;
description: string;
schema: ZodRawShape;
handler: ToolHandler<any>;
}
@Injectable()
export class ToolRegistry {
private readonly tools: ToolDefinition[] = [];
register(tool: ToolDefinition): void {
this.tools.push(tool);
}
getAll(): ToolDefinition[] {
return this.tools;
}
}Nothing fancy. Domains push in during init, McpService reads out at startup.
Here's the orders domain registering two tools. The pattern is the same for every domain:
// src/orders/orders.tools.ts
import { Injectable, OnModuleInit } from "@nestjs/common";
import { z } from "zod";
import { ToolRegistry } from "../mcp/tool-registry";
import { OrdersService } from "./orders.service";
@Injectable()
export class OrdersTools implements OnModuleInit {
constructor(
private readonly registry: ToolRegistry,
private readonly orders: OrdersService,
) {}
onModuleInit() {
this.registry.register({
name: "list_orders",
description: "List recent orders for a customer.",
schema: {
customer_id: z.string().uuid().describe("UUID of the customer"),
limit: z.number().int().min(1).max(50).default(10),
},
handler: async ({ customer_id, limit }) => {
const orders = await this.orders.findByCustomer(customer_id, limit);
return {
content: [{ type: "text", text: JSON.stringify(orders, null, 2) }],
};
},
});
this.registry.register({
name: "get_order_by_id",
description: "Fetch a single order and its line items.",
schema: {
order_id: z.string().uuid().describe("UUID of the order to fetch"),
},
handler: async ({ order_id }) => {
const order = await this.orders.findById(order_id);
if (!order) {
return {
isError: true,
content: [{ type: "text", text: `Order ${order_id} not found.` }],
};
}
return {
content: [{ type: "text", text: JSON.stringify(order, null, 2) }],
};
},
});
}
}Three things here that matter:
z.string().uuid().describe(...) puts a description into the JSON schema the model sees when deciding how to call your tool. Skip it and the model guesses from field names alone, which works until it doesn't.
z.infer<z.ZodObject<T>> gives you typed arguments inside the handler. No casting, no any. The SDK validates input before your handler runs, so a malformed UUID never reaches findById.
isError: true on a missing record is intentional. Never throw from a tool handler. The host won't catch it cleanly and the server process dies silently. Return isError: true with a message the model can act on.
McpService wires the registry into the SDK and connects the transport:
// src/mcp/mcp.service.ts
import { Injectable, OnModuleInit, Logger } from "@nestjs/common";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { ToolRegistry } from "./tool-registry";
@Injectable()
export class McpService implements OnModuleInit {
private readonly logger = new Logger(McpService.name);
readonly server = new McpServer({
name: "my-api-mcp",
version: "1.0.0",
});
constructor(private readonly registry: ToolRegistry) {}
async onModuleInit() {
for (const tool of this.registry.getAll()) {
this.server.tool(
tool.name,
tool.description,
tool.schema,
async (args) => {
const start = Date.now();
try {
const result = await tool.handler(args);
this.logger.log({ tool: tool.name, latencyMs: Date.now() - start });
return result;
} catch (err) {
this.logger.error({ tool: tool.name, err });
return {
isError: true,
content: [{ type: "text", text: String(err) }],
};
}
},
);
}
const transport = new StdioServerTransport();
await this.server.connect(transport);
this.logger.log("MCP server running on stdio");
}
}OnModuleInit ordering matters here. NestJS initializes modules in import order, so McpModule should import the domain modules and their OnModuleInit hooks then fire before McpService.onModuleInit. By the time the for loop runs, all domain tools are already in the registry.
Stdio is right when your MCP server lives on the same machine as the LLM host: Claude Desktop, Cursor, or a local agent script. The host spawns your NestJS process and talks to it over stdin/stdout, with no ports, auth, or TLS. For developer tools and anything single-machine, stop here.
HTTP is for remote deployments, multiple users, or when you need to pass per-request auth context into tool handlers. The current MCP spec prefers Streamable HTTP over the older SSE transport (SSE needed two endpoints; Streamable HTTP collapses it to one).
To expose the MCP endpoint over HTTP, mount the transport on a NestJS controller:
// src/mcp/mcp.controller.ts
import { All, Controller, Req, Res, UseGuards } from "@nestjs/common";
import { Request, Response } from "express";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { randomUUID } from "crypto";
import { McpService } from "./mcp.service";
import { BearerAuthGuard } from "./bearer-auth.guard";
@Controller("mcp")
@UseGuards(BearerAuthGuard)
export class McpController {
private readonly sessions = new Map<string, StreamableHTTPServerTransport>();
constructor(private readonly mcp: McpService) {}
@All()
async handle(@Req() req: Request, @Res() res: Response) {
const sessionId = req.headers["mcp-session-id"] as string | undefined;
if (sessionId && this.sessions.has(sessionId)) {
const transport = this.sessions.get(sessionId)!;
await transport.handleRequest(req, res, req.body);
return;
}
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (id) => this.sessions.set(id, transport),
});
transport.onclose = () => {
if (transport.sessionId) this.sessions.delete(transport.sessionId);
};
await this.mcp.server.connect(transport);
await transport.handleRequest(req, res, req.body);
}
}Each new connection gets a StreamableHTTPServerTransport. Subsequent requests from the same session resume it via the mcp-session-id header. Sessions are in-memory here. To scale horizontally, push session state to Redis or go stateless (set sessionIdGenerator: undefined and pass any needed context on each request instead).
A NestJS guard that validates bearer tokens is the right place for MCP server authentication. It runs before the route handler, so a rejected token never reaches the transport layer:
// src/mcp/bearer-auth.guard.ts
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from "@nestjs/common";
import { AuthService } from "../auth/auth.service";
@Injectable()
export class BearerAuthGuard implements CanActivate {
constructor(private readonly auth: AuthService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest();
const header = req.headers["authorization"] ?? "";
const token = header.startsWith("Bearer ") ? header.slice(7) : null;
if (!token) throw new UnauthorizedException("Missing bearer token");
const user = await this.auth.validateToken(token);
if (!user) throw new UnauthorizedException("Invalid token");
req.user = user;
return true;
}
}Getting the current user into a tool handler without threading it through every call signature is the awkward part. AsyncLocalStorage solves it: set the context once in middleware, then read it from any injectable without changing the call chain.
// src/mcp/request-context.ts
import { AsyncLocalStorage } from "async_hooks";
export interface RequestContext {
userId: string;
tenantId: string;
}
export const requestContext = new AsyncLocalStorage<RequestContext>();Wire it in main.ts:
app.use((req: Request, _res: Response, next: NextFunction) => {
const ctx = {
userId: req.user?.id ?? "",
tenantId: req.user?.tenantId ?? "",
};
requestContext.run(ctx, next);
});Any tool handler can now call requestContext.getStore() to get the current user. The OrdersService can scope queries to the right tenant without the tool needing to know how auth works.
NestJS's built-in logger emits unstructured text. For tool call observability you want structured JSON, which is what the nestjs-pino logger gives you. Install it and its two peers:
npm install nestjs-pino pino-http pino-prettyRegister the logger module at the root, above McpModule:
// app.module.ts
import { LoggerModule } from "nestjs-pino";
@Module({
imports: [
LoggerModule.forRoot({
pinoHttp: {
level: process.env.NODE_ENV === "production" ? "info" : "debug",
},
}),
McpModule,
],
})
export class AppModule {}Then buffer the bootstrap logs and swap the app logger in main.ts:
// main.ts
import { Logger } from "nestjs-pino";
const app = await NestFactory.create(AppModule, { bufferLogs: true });
app.useLogger(app.get(Logger));Then replace new Logger(McpService.name) with the injected PinoLogger and your per-call log line becomes structured JSON:
{ "tool": "get_order_by_id", "latencyMs": 43, "userId": "u_abc", "level": 30 }Log these fields per call: tool, latencyMs, userId, sessionId, and error when isError: true. That's enough to answer "which tools are slow, which are failing, and for which users" without digging through traces.
The handler inside each ToolDefinition is a plain async function. You don't need to boot NestJS, start a transport, or touch the SDK to test it. Just call it:
// src/orders/orders.tools.spec.ts
import { ToolRegistry } from "../mcp/tool-registry";
import { OrdersTools } from "./orders.tools";
describe("OrdersTools", () => {
let registry: ToolRegistry;
let ordersService: jest.Mocked<any>;
beforeEach(() => {
registry = new ToolRegistry();
ordersService = {
findById: jest.fn(),
findByCustomer: jest.fn(),
};
const tools = new OrdersTools(registry, ordersService);
tools.onModuleInit();
});
function getTool(name: string) {
return registry.getAll().find((t) => t.name === name)!;
}
it("returns isError when order not found", async () => {
ordersService.findById.mockResolvedValue(null);
const result = await getTool("get_order_by_id").handler({
order_id: "abc",
});
expect(result.isError).toBe(true);
});
it("returns serialized order on success", async () => {
ordersService.findById.mockResolvedValue({ id: "abc", total: 99 });
const result = await getTool("get_order_by_id").handler({
order_id: "abc",
});
expect(result.content[0].text).toContain("abc");
});
});No transports, no SDK, no port binding. The handler is a function, so test it like one. This is the test you'll run most often; keep it fast.
For confidence that the full DI graph wires up correctly:
// src/mcp/mcp.integration.spec.ts
import { Test } from "@nestjs/testing";
import { McpModule } from "./mcp.module";
import { McpService } from "./mcp.service";
import { OrdersService } from "../orders/orders.service";
describe("McpService integration", () => {
let mcpService: McpService;
beforeAll(async () => {
const module = await Test.createTestingModule({
imports: [McpModule],
})
.overrideProvider(OrdersService)
.useValue({ findById: jest.fn(), findByCustomer: jest.fn() })
.compile();
mcpService = module.get(McpService);
});
it("has list_orders registered", () => {
const names = mcpService["registry"].getAll().map((t) => t.name);
expect(names).toContain("list_orders");
expect(names).toContain("get_order_by_id");
});
});This boots the full module tree (minus the real OrdersService) and confirms all tools registered before the transport starts. Useful as a smoke test after refactoring module imports.
Three tools that fit in a conversation window are more useful than thirty that force the model to guess.
Name tools as verb_noun pairs. list_orders, get_order_by_id, cancel_order. The model predicts which tool to call from the name alone. A name like query or execute forces it to read the description every time, which it doesn't always do reliably.
One tool, one job. get_order_by_id takes a UUID and returns one order. It doesn't also accept email addresses, return the customer record, or optionally include invoices. Narrow scope means predictable calls.
Namespace across domains. Once you have more than one domain, prefix by domain: orders_list, orders_get, users_get. Or orders:list if your host supports it. Either way, grouping prevents the model from confusing tools with similar signatures.
Call out side effects explicitly. If a tool creates or mutates something, say so in the description: "Creates a refund. This action cannot be undone." The model reads descriptions when deciding whether to call something. Passive-sounding tool names for destructive operations are a footgun.
z.describe() on every non-obvious argument. z.string().describe("UUID of the order to fetch") puts that text into the JSON schema the host sends to the model. Skip it on obvious fields like limit; don't skip it on domain identifiers.
Your REST API already has the business logic, validation, and access control. MCP is a new transport layer over the top of it, one that lets an agent drive the same operations a human would. The NestJS patterns here are the same ones you'd apply to any new controller. The only thing that changes is who's calling it.
Wire it to a local model, watch it run, and then ask yourself which operations are worth letting a model perform unsupervised. The protocol leaves that question to you.
Free checklist
The 28 checks I run before an MCP server touches real users: transport, tool design, auth, errors, testing, and deployment. Distilled from real projects in TypeScript and NestJS.
Pick stdio for local, single-user servers; Streamable HTTP for anything remote or multi-client.
Never write logs to stdout on stdio transport.
Handle SIGINT/SIGTERM and close the transport cleanly.
On Streamable HTTP, decide session policy explicitly (stateless vs session-per-client).
One tool = one decision the model can make. Split tools that take a 'mode' argument.
Write descriptions for the model, not for docs: when to use it, when not to.
Validate every input with a schema (Zod) and return typed, structured errors.
Keep results small and paginated; return references, not dumps.
Use resources for read-only reference data instead of tools that fetch it.
Mark destructive tools and gate them behind confirmation or scopes.
The full checklist is 28 checks, each with the reasoning behind it. Still ahead: Errors & observability, Testing, Deployment & operations. Enter your email and it unlocks right here.
Includes a Markdown download for your repo.
Unsubscribe anytime.