The hello-world MCP server is three functions in a flat file. Your NestJS codebase is forty services, a database connection, a JWT guard, and a Pino logger. The official SDK docs don't tell you how to get from one to the other.
This post does. If you haven't built a basic MCP server yet, start with Build an MCP Server from Scratch first - this picks up where that one ends.
The goal is to make MCP tools first-class citizens in your NestJS module tree. Each domain owns its 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.ts
The flow: domain 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.
A plain injectable that collects tool definitions before anything connects:
// src/mcp/tool-registry.ts
import { Injectable } from "@nestjs/common";
import { z, ZodRawShape } from "zod";
export type ToolHandler<T extends ZodRawShape> = (
Occasional notes on software, tools, and things I learn. No spam.
Unsubscribe anytime.
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 - McpModule should import the domain modules, so their OnModuleInit hooks 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, talks over stdin/stdout, and that's it. No ports, no auth, no 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 - if you need to scale horizontally, push session state to Redis or just go stateless (set sessionIdGenerator: undefined and push any needed context into each request instead).
A NestJS guard that validates bearer tokens is the right place for auth - 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 cleanly - set the context once in middleware, 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.
npm install nestjs-pino pino-http pino-pretty// 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 {}// 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 }The fields worth logging 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 - 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 - 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 just 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 actually run, and then ask yourself which operations are genuinely worth letting a model perform unsupervised. That's the interesting question - and the one the protocol deliberately leaves to you.