NestJS MCP server
npm i @nestjs/mcp @modelcontextprotocol/server zod
npm i -D @modelcontextprotocol/client # in-memory test client
Zod 4.2+ and ArkType work directly; Valibot through toStandardJsonSchema(); an existing JSON Schema via fromJsonSchema(). Zod 3 schemas are refused at startup.
McpModule.forRoot({
name: 'cat-store', version: '1.0.0',
instructions: 'Look orders up with find-order before changing them.', // tells the model how to use the server
}),
app.listen(3000) then serves the endpoint at /mcp. Inspect it with npx @modelcontextprotocol/inspector. At startup the module scans providers (all modules, or only include) for decorated methods. It fails fast on duplicate names, invalid schemas, or scopes declared without auth.
Primitives: methods on providers
| Primitive | Decorator | Who uses it | Called with |
|---|---|---|---|
| Tool (an action) | @Tool({ name, description, inputSchema, outputSchema?, annotations?, scopes? }) | the model | validated input, ctx |
| Resource (data) | @Resource({ uri, mimeType }) / @ResourceTemplate({ uriTemplate: 'orders://{orderId}' }) | the client, often attached by the user | {} or the template variables, ctx (ctx.uri) |
| Prompt (a template) | @Prompt({ name, description, argsSchema }) | the user picks one | validated args, ctx |
@Injectable() // a plain provider, listed in a module's providers
export class OrdersTools {
constructor(private readonly orders: OrdersService) {}
@Tool({
name: 'find-order',
description: 'Find an order by its id, or the latest orders of a customer by email',
inputSchema: z.object({
orderId: z.string().optional().describe('The order id, such as ord_123'),
email: z.email().optional().describe("The customer's email address"),
}),
annotations: { readOnlyHint: true }, // client may run it without asking the user
})
async find({ orderId, email }: { orderId?: string; email?: string }) {
const found = orderId ? [await this.orders.findById(orderId)] : await this.orders.latestFor(email!);
if (!found[0]) throw new NotFoundException('No such order'); // → tool result with isError: true
return found;
}
@Tool({
name: 'change-address',
description: 'Change the delivery address of an order that has not shipped yet',
inputSchema: z.object({ orderId: z.string(), address: z.string().min(10) }),
outputSchema: z.object({ orderId: z.string(), address: z.string(), status: z.string() }),
scopes: ['orders:write'],
})
async changeAddress({ orderId, address }: { orderId: string; address: string }, ctx: McpContext) {
const o = await this.orders.changeAddress(orderId, address, ctx.authInfo?.clientId);
return { orderId: o.id, address: o.address, status: o.status }; // → structuredContent + JSON text
}
}
Return values: a tool's string becomes a text block. An object becomes structuredContent if there's an outputSchema, and JSON text otherwise. Return a CallToolResult yourself for images, audio or embedded resources; undefined gives an empty result. A resource's string is sent as text, a Buffer/Uint8Array as a blob, an object as JSON. A prompt's string becomes one user message; an array returns PromptMessage[]. Tool names are 1–128 characters from A-Z a-z 0-9 _ - . and default to the method name.
Write names, descriptions and .describe() texts for the model. They're all it knows: say what the tool does, when to use it, whether it changes something, and give an example value for each argument. Use resources for content loaded as-is; use tools for lookups that take arguments.
Call context
ctx: McpContext has these properties:
kind,name,uriauthInfo(clientId,scopes,expiresAt,extra)request(the platform request;undefinedover stdio)requestInfotransport('http' | 'stdio')era('modern'2026-07-28 /'legacy'2025)signal: pass it tofetchand DB calls; callthrowIfAborted()in loopsreportProgress(progress, total?, message?)server(SDK context)
To ask the user (confirmations, missing values), return inputRequired({ inputRequests: { confirm: inputRequired.elicit({ message, requestedSchema }) } }). On the retry, read the answer with acceptedContent(ctx.server.mcpReq.inputResponses, 'confirm', Schema). Over HTTP this only works for 2026-07-28 clients, because the endpoint is stateless. Over stdio it works for both eras.
Providers stay singletons. Read per-call data from ctx, not from request-scoped providers.
Enhancers and errors
- Guards, interceptors, pipes and filters run as on routes, with
context.getType() === 'mcp'.McpExecutionContext.create(context)providesgetInput(),getContext(),getKind(),getName(),getRequest()andgetAuthInfo(). - Global enhancers run on MCP calls and on the carrying HTTP request. HTTP-only enhancers must check
context.getType() === 'http'before callingswitchToHttp(). - A global
ValidationPipe({ transform: true })validates DTO-typed input. Custom param decorators go after(input, ctx, @CurrentUser() user). - Errors thrown by tools become
isError: trueresults the model can recover from. AnHttpExceptionkeeps its message; any other error becomesInternal server errorand is logged. - Resources and prompts answer with JSON-RPC errors:
NotFoundException→ resource not found,BadRequestException→ invalid params. - Filters: check
host.getType<McpContextType>() === 'mcp'. Whatever the filter returns becomes the result; to send a JSON-RPC error, throw aProtocolError.
Authorization (network-reachable servers)
McpModule.forRootAsync({
imports: [ConfigModule, AuthModule],
inject: [ConfigService, IdentityProviderTokenVerifier],
useFactory: (config: ConfigService, verifier: IdentityProviderTokenVerifier) => ({
name: 'cat-store', version: config.get('VERSION'),
auth: {
verifier, // implements McpTokenVerifier.verifyAccessToken(token) → AuthInfo | null
resource: config.get('MCP_RESOURCE'), // canonical public URL = token audience
authorizationServers: [config.get('IDENTITY_PROVIDER_URL')],
scopesSupported: ['orders:read', 'orders:write'],
},
}),
}),
- The server is an OAuth 2.1 resource server: it verifies tokens and never issues them.
- It serves RFC 9728 metadata at
/.well-known/oauth-protected-resource/mcp. Requests without a token get a 401 with aWWW-Authenticate: Bearerchallenge, which starts the client's sign-in. - Verifiers:
- JWTs: use
JwtVerifierfrom@nestjs/authentication(checks signature, issuer, audience and expiry). - Opaque tokens: call the RFC 7662 introspection endpoint.
- The returned
AuthInfomust carryresource === auth.resourceand a futureexpiresAt, otherwise the token is refused with 401. - Throw a 401 error to refuse a token; any other error becomes a 500.
- JWTs: use
- Missing
scopeson a primitive → 403 with step-up challenge.auth.requiredScopesapplies to every request. - With
@nestjs/authentication≥ 0.1.3 and@nestjs/authorization≥ 0.1.1:- Their global guards authenticate each call, and
@CurrentUser()and@Can(Policy, 'action')work in tools. - Set
decorators: [Public()]on the endpoint soauthhandles the challenge. - A tool without
@Can()is denied.
- Their global guards authenticate each call, and
- Internal-only servers can skip OAuth and use
decorators: [UseGuards(ApiKeyGuard)]on the endpoint.
Transports
- Streamable HTTP is served by default;
path: falsedisables it. The endpoint is a normal controller, so global prefix, versioning (endpointVersion), middleware and CORS all apply. It is stateless: no sticky sessions are needed. Both protocol eras are served (http.legacy: 'reject'serves 2026-07-28 only).http.responseModecan be'auto','json'or'sse'. - stdio, for assistants that launch a local process: create
stdio.tswithNestFactory.createApplicationContext(AppModule, { logger: new McpStdioLogger() }), thenawait app.get(McpStdioServer).listen(); await app.close();. Anything else written to stdout breaks the protocol. Credentials come fromprocess.env; scopes aren't checked, but guards run.
See references/mcp-reference.md for the full options table, production settings, the stdio client config, testing patterns and the migration map.
Workflow
- 1. Install; check for Zod 4.2+;
McpModule.forRootwithname,version,instructions. - 2. Add tools, resources and prompts on providers, with model-oriented descriptions,
.describe()on every argument,readOnlyHinton read-only tools, andoutputSchemawhere the result is structured. - 3. Make sure HTTP-only global enhancers check
getType(); add MCP guards and filters as needed. - 4. Network-reachable servers: add
auth(verifier, resource, authorization servers, scopes),allowedHosts/allowedOrigins, CORS headers and the body limit. Local servers: addstdio.tswithMcpStdioLogger. - 5. Tests: unit-test methods directly; run e2e with
connectMcpClient(moduleRef, { token })against a test verifier; test the HTTP route over a socket. Build, lint, run tests, start the app and list tools with the Inspector.
Reference files
references/mcp-reference.md: module options, decorator options, HTTP hardening (Origin, Host, CORS headers, body size), the stdio client config, NestJS Observe naming, the production checklist, testing (tokens and scopes, protocol eras, McpRegistry checks, HTTP and stdio tests), and migration from@rekog/mcp-nest1.x/2.x. Read when configuring production, writing tests or migrating.
Sources: Overview · Primitives · Transports · Testing · Migration