We had a NestJS API that already knew how to do everything an agent might want. Auth, tenancy, business logic, the works. What we didn't have was a way for an LLM to call any of it. MCP (Model Context Protocol) is the standard for that, and the TypeScript SDK handles the wire protocol. The part nobody writes down is how to fit it into NestJS without abandoning dependency injection and ending up with a 900-line file of closures.
This post walks through the setup I landed on. The examples use a fictional orders domain, but the structure is what I run in production.
A note on versions first. Everything below uses the v1 SDK, @modelcontextprotocol/sdk, which is the supported production line. A v2 (@modelcontextprotocol/server) is in beta as I write this, with a different package layout. If you're starting today, v1 is still the safe choice, and the concepts transfer.
why bother with NestJS at all
You can stand up an MCP server in 40 lines of Express. The SDK README shows you how. That's fine for a demo, but production tool handlers need the same things your REST handlers need. Database access, per-tenant scoping, logging, existing service classes. If your app is NestJS, the cheapest way to get all of that is to keep the tools inside the same DI container.
The goal is that a tool handler looks like any other Nest provider. It injects OrdersService, it gets tested with the Nest testing module, and the MCP plumbing stays in one thin layer.
module layout
The layout I use has three pieces. Tool classes, a server factory, and a controller that owns the transport.
Each tool class is a normal injectable that knows how to register itself on an McpServer instance. I pass a per-request context in, which matters later for tenancy.
// src/mcp/tools/orders.tools.ts
import { Injectable } from '@nestjs/common';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { OrdersService } from '../../orders/orders.service';
export interface ToolContext {
tenantId: string;
environment: 'sandbox' | 'production';
}
@Injectable()
export class OrdersTools {
constructor(private readonly orders: OrdersService) {}
register(server: McpServer, ctx: ToolContext): void {
server.registerTool(
'list_orders',
{
title: 'List orders',
description:
'List recent orders for the current store, newest first. ' +
'Use status to narrow the result. Returns order id, status, ' +
'total in cents, and created date.',
inputSchema: {
status: z
.enum(['pending', 'shipped', 'refunded'])
.optional()
.describe('Only return orders in this state'),
limit: z
.number()
.int()
.min(1)
.max(50)
.default(10)
.describe('Maximum number of orders to return'),
},
},
async ({ status, limit }) => {
const orders = await this.orders.list({
tenantId: ctx.tenantId,
environment: ctx.environment,
status,
limit,
});
const output = { orders };
return {
content: [{ type: 'text', text: JSON.stringify(output) }],
structuredContent: output,
};
},
);
}
}
Two SDK details trip people up. inputSchema is a raw Zod shape, an object of key to schema, not z.object(...). And the handler returns a content array, optionally with structuredContent when you also declare an outputSchema.
Why register(server, ctx) instead of a fancier abstraction where tools declare metadata and a registry loops over them? I tried the registry version. The generics needed to keep handler args typed from the Zod shape got ugly fast, and the SDK already infers those types perfectly when you call registerTool directly. Letting each class call the SDK keeps the types and costs one interface.
The factory collects the tool classes through a multi-provider token and builds a fresh server per request.
// src/mcp/mcp.module.ts
import { Module } from '@nestjs/common';
export const MCP_TOOLS = Symbol('MCP_TOOLS');
@Module({
imports: [OrdersModule],
controllers: [McpController],
providers: [
OrdersTools,
RefundsTools,
{
provide: MCP_TOOLS,
useFactory: (orders: OrdersTools, refunds: RefundsTools) => [orders, refunds],
inject: [OrdersTools, RefundsTools],
},
McpServerFactory,
],
})
export class McpModule {}
// src/mcp/mcp-server.factory.ts
import { Inject, Injectable } from '@nestjs/common';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
@Injectable()
export class McpServerFactory {
constructor(@Inject(MCP_TOOLS) private readonly tools: OrdersTools[]) {}
create(ctx: ToolContext): McpServer {
const server = new McpServer({ name: 'orders-mcp', version: '1.0.0' });
for (const tool of this.tools) {
tool.register(server, ctx);
}
return server;
}
}
wiring the transport
For a server that sits behind an agent product, I run the Streamable HTTP transport in stateless mode. No session tracking, one server instance per request, nothing to clean up between requests except the request itself. Sessions buy you resumability and server-initiated notifications, and until you need those, statelessness keeps horizontal scaling boring.
// src/mcp/mcp.controller.ts
import { Controller, Post, Req, Res, UseGuards } from '@nestjs/common';
import { Request, Response } from 'express';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
@Controller('mcp')
@UseGuards(ApiKeyGuard)
export class McpController {
constructor(private readonly factory: McpServerFactory) {}
@Post()
async handle(@Req() req: Request, @Res() res: Response): Promise<void> {
const ctx: ToolContext = req.tenantContext; // set by ApiKeyGuard
const server = this.factory.create(ctx);
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined, // stateless
});
res.on('close', () => {
transport.close();
server.close();
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
}
}
Injecting @Res() puts Nest into library-specific mode for this route, so response interceptors won't run here. That's intentional. The transport owns the response, including SSE streaming, and anything that touches the response after it will corrupt the stream. Keep this controller dumb.
Nest's default body parser has already parsed JSON by the time the handler runs, which is why req.body gets passed through as the third argument.
schemas an LLM can actually use
This is where most MCP servers quietly fail. The model only sees the tool name, the description, and the schema. It cannot read your code. Things that made a real difference for us, in rough order of impact.
Describe behavior, not types. limit: number tells the model nothing it can't see from the schema. "Maximum number of orders to return, newest first" changes how it calls the tool.
Constrain everything you can. z.enum(['pending', 'shipped', 'refunded']) means the model can't invent a cancelled status that your database has never heard of. Ranges on numbers, .default() where a sane default exists.
Prefer several small tools over one flexible tool. We started with a query_orders tool that took a filter object. The model produced creative filters, most of them invalid. Splitting it into list_orders, get_order, and search_orders_by_customer cut tool-call errors to almost nothing. Boring tools get called correctly.
Return errors as results, not exceptions. A thrown exception becomes a generic protocol error and the model learns nothing. Set isError: true and say what went wrong in plain language, ideally with what to do instead.
return {
content: [{
type: 'text',
text: `Order ${orderId} not found for this store. Use list_orders to see valid ids.`,
}],
isError: true,
};
That last sentence is a nudge the model will actually follow.
scoping per tenant and environment
The tempting design is a tenantId parameter on every tool. Don't. The model chooses parameter values, and a model should never choose which tenant's data it reads. Same argument as never trusting a client-supplied tenant id in a REST API, except the client here is a language model that will cheerfully hallucinate one.
Tenant identity has to come from the credential. In the controller above, ApiKeyGuard resolves the API key to a tenant and an environment before the MCP layer sees the request, and the factory bakes that context into every handler via the closure over ctx. A tool physically cannot query another tenant because the tenant id never appears in its schema.
Environment scoping works the same way and matters just as much. Our sandbox and production keys map to different environment values, and the services route to different backends from there. An agent wired up with a sandbox key can call refund_order all day without touching real money.
One consequence of per-request server construction is that you can also vary the tool list by context. Sandbox keys get a seed_test_orders tool, production keys don't. Registration happens in register(), so a plain if on ctx.environment is all it takes.
testing handlers
Two layers have earned their keep.
Unit tests hit the tool class directly through the Nest testing module, with the domain service mocked. Nothing MCP-specific about these, which is the point of keeping handlers as providers.
Integration tests run the real protocol without HTTP. The SDK ships an in-memory transport pair for exactly this.
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
it('lists orders scoped to the tenant', async () => {
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const server = factory.create({ tenantId: 'tenant-a', environment: 'sandbox' });
await server.connect(serverTransport);
const client = new Client({ name: 'test-client', version: '1.0.0' });
await client.connect(clientTransport);
const result = await client.callTool({
name: 'list_orders',
arguments: { limit: 5 },
});
expect(result.isError).toBeFalsy();
expect(ordersService.list).toHaveBeenCalledWith(
expect.objectContaining({ tenantId: 'tenant-a' }),
);
});
This catches a class of bug unit tests miss, like schema definitions that reject arguments your handler expects, or handlers returning shapes that fail outputSchema validation. It's also the right place to assert the tenant-scoping property, since it exercises the same factory path production uses.
What these tests don't tell you is whether a model can use your tools well. For that we keep a small eval script that points a real agent at the server with a handful of scripted tasks and checks the outcomes. It's crude and occasionally flaky, and it has still caught two bad tool descriptions before users did.
The whole thing came out to around 300 lines on top of an existing NestJS app, and most of that is tool definitions we'd want regardless. Keep the transport in one controller, resolve tenancy from credentials before the SDK ever runs, and treat descriptions as the API surface they are. The protocol part turns out to be the easy bit.
Top comments (0)