---
title: "Building an MCP Server in TypeScript with NestJS"
description: "Build a NestJS MCP server in TypeScript on a real backend: dependency injection, Zod-typed tools, stdio and Streamable HTTP, bearer auth, and testable handlers."
canonical: https://omiid.me/notebook/52/building-an-mcp-server-in-typescript-with-nestjs
author: Omid Sayfun
published: 2026-05-14
updated: 2026-08-09
---

# Building an MCP Server in TypeScript with NestJS

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](https://omiid.me/notebook/51/build-an-mcp-server-from-scratch). This post picks up where that one ends.

## Each domain owns its tools

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.ts
```

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.

## The registry collects tools before anything connects

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:

```ts
// 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.

## Define tools with a Zod schema and a typed handler

Here's the orders domain registering two tools. The pattern is the same for every domain:

```ts
// 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 registers every tool at startup

`McpService` wires the registry into the SDK and connects the transport:

```ts
// 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 for local hosts, Streamable HTTP for remote

**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:

```ts
// 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).

## Put bearer auth in a guard, not the transport

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:

```ts
// 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.

```ts
// 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`:

```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.

## Log every tool call as structured JSON with Pino

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:

```bash
npm install nestjs-pino pino-http pino-pretty
```

Register the logger module at the root, above `McpModule`:

```ts
// 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`:

```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:

```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.

## Test handlers as plain functions

### Unit test the handler

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:

```ts
// 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.

### Integration test with the NestJS testing module

For confidence that the full DI graph wires up correctly:

```ts
// 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.

## Design tools the model can pick without guessing

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.
