MCP Server NestJS Module Library
adrian-d-hidalgo/nestjs-mcp-server · 39 stars · TypeScript · MIT
MCP server
Install
The repo has no one-line install. Follow its README.
Files
MCP Server NestJS Module Library
Overview
NestJS MCP Server is a modular library for building Model Context Protocol (MCP) servers using NestJS. It provides decorators, modules, and integration patterns to expose MCP resources, tools, and prompts in a scalable, maintainable way. This project is a wrapper for the official @modelcontextprotocol/sdk and is always kept compatible with its types and specification.
Table of Contents
- Installation
- Quickstart
- What is MCP?
- Core Concepts
- Module API
- Module Usage
- Capabilities
- Dynamic Capabilities
- Guards
- Authentication & authorization
- Statelessness
- Transport Options
- MCP 2026-07-28 features
- Migrating from
1.xto2.x - Inspector Playground
- Examples
- Changelog
- License
- Contributions
Installation
npm install @nestjs-mcp/server @modelcontextprotocol/sdk zod # or yarn add @nestjs-mcp/server @modelcontextprotocol/sdk zod # or pnpm add @nestjs-mcp/server @modelcontextprotocol/sdk zod
Quickstart
Register the MCP module in your NestJS app and expose a simple tool:
import { Module } from '@nestjs/common';
import { CallToolResult } from '@modelcontextprotocol/sdk/types';
import { Resolver, Tool, McpModule } from '@nestjs-mcp/server';
@Resolver()
export class HealthResolver {
/**
* Simple health check tool
*/
@Tool({ name: 'server_health_check' })
healthCheck(): CallToolResult {
return {
content: [
{
type: 'text',
text: 'Server is operational. All systems running normally.',
},
],
};
}
}
@Module({
imports: [
McpModule.forRoot({
name: 'My MCP Server',
version: '1.0.0',
}),
],
providers: [HealthResolver],
})
export class AppModule {}
What is MCP?
The Model Context Protocol (MCP) is an open protocol for connecting LLMs to external data, tools, and prompts. MCP servers expose resources (data), tools (actions), and prompts (conversational flows) in a standardized way, enabling seamless integration with LLM-powered clients.
- See the Anthropic announcement for more background.
Core Concepts
Server
The MCP Server is the main entry point for exposing capabilities to LLMs. It manages the registration and discovery of resources, tools, and prompts.
Resource
A Resource represents structured data or documents that can be queried or retrieved by LLMs. Resources are typically read-only and are identified by a unique URI.
- Learn more: MCP Resources documentation
Tool
A Tool is an action or function that can be invoked by LLMs. Tools may have side effects and can accept parameters to perform computations or trigger operations.
- Learn more: MCP Tools documentation
Prompt
A Prompt defines a conversational flow, template, or interaction pattern for LLMs. Prompts help guide the model's behavior in specific scenarios.
- Learn more: MCP Prompts documentation
See the Capabilities section for implementation details and code examples.
Module API
McpModule.forRoot
Registers the MCP Server globally in your NestJS application.
Parameters:
options: McpModuleOptions— Main server configuration object:name: string: The name of your MCP server.version: string: The version of your MCP server.instructions?: string: Optional description of the MCP server for the client.capabilities?: Record<string, unknown>: Optional additional capabilities metadata.providers?: Provider[]: Optional array of NestJS providers to include in the module.imports?: any[]: Optional array of NestJS modules to import.logging?: McpLoggingOptions: Optional logging configuration:enabled?: boolean(default:true): Enable/disable logging.level?: 'error' | 'warn' | 'log' | 'debug' | 'verbose'(default:'verbose'): Set the logging level.
transports?: McpModuleTransportOptions: Optional transport configuration (see Transport Options).protocolOptions?: Record<string, unknown>: Optional parameters passed directly to the underlying@modelcontextprotocol/sdkserver instance.
Returns:
- A dynamic NestJS module with all MCP providers registered.
Example:
import { Module } from '@nestjs/common';
import { McpModule } from '@nestjs-mcp/server';
@Module({
imports: [
McpModule.forRoot({
name: 'My Server',
version: '1.0.0',
instructions: 'A server providing utility tools and data.',
logging: { level: 'log' },
transport: { legacy: 'reject' }, // Modern-era clients only
// ...other MCP options
}),
],
})
export class AppModule {}
McpModule.forRootAsync
Registers the MCP Server globally using asynchronous options, useful for integrating with configuration modules like @nestjs/config.
Note:
- The
importsarray should include any modules that provide dependencies required by youruseFactory(e.g.,ConfigModuleif you injectConfigService).- Use
forRootAsynconly once in your root module (AppModule).- See
McpModuleAsyncOptionsfor all available options.
Parameters:
options: McpModuleAsyncOptions— Asynchronous configuration object:imports?: any[]: Optional modules to import before the factory runs.useFactory: (...args: any[]) => Promise<McpModuleOptions> | McpModuleOptions: A factory function that returns theMcpModuleOptions.inject?: any[]: Optional providers to inject into theuseFactory.
Returns:
- A dynamic NestJS module.
Example (with ConfigModule):
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { McpModule } from '@nestjs-mcp/server';
@Module({
imports: [
ConfigModule.forRoot(), // Make sure ConfigModule is imported
McpModule.forRootAsync({
imports: [ConfigModule], // Import ConfigModule here too
useFactory: (configService: ConfigService) => ({
name: configService.get<string>('MCP_SERVER_NAME', 'Default Server'),
version: configService.get<string>('MCP_SERVER_VERSION', '1.0.0'),
instructions: configService.get<string>('MCP_SERVER_DESC'),
logging: {
level: configService.get('MCP_LOG_LEVEL', 'verbose'),
},
// ... other options from configService
}),
inject: [ConfigService], // Inject ConfigService into the factory
}),
],
})
export class AppModule {}
McpModule.forFeature
Registers additional MCP resources, tools, or prompts within a feature module. Use this to organize large servers into multiple modules. Resolvers containing MCP capabilities must be included in the providers array of the feature module.
Parameters:
options?: McpFeatureOptions(Currently unused, reserved for future enhancements).
Returns:
- A dynamic module.
Example:
// src/status/status.resolver.ts
import { Resolver, Tool } from '@nestjs-mcp/server';
import { CallToolResult } from '@modelcontextprotocol/sdk/types';
@Resolver('status')
export class StatusResolver {
@Tool({ name: 'health_check' })
healthCheck(): CallToolResult {
return { content: [{ type: 'text', text: 'OK' }] };
}
}
// src/status/status.module.ts
import { Module } from '@nestjs/common';
import { McpModule } from '@nestjs-mcp/server';
import { StatusResolver } from './status.resolver';
@Module({
imports: [McpModule.forFeature()], // Import forFeature here
providers: [StatusResolver], // Register your resolver
})
export class StatusModule {}
Module Usage
This library provides two main ways to register MCP capabilities in your NestJS application:
1. Global Registration with McpModule.forRoot
Use McpModule.forRoot in your root application module to configure and register the MCP server globally. This is required for every MCP server application.
import { Module } from '@nestjs/common';
import { McpModule } from '@nestjs-mcp/server';
import { PromptsResolver } from './prompts.resolver';
@Module({
imports: [
McpModule.forRoot({
name: 'My MCP Server',
version: '1.0.0',
// ...other MCP options
}),
],
providers: [PromptsResolver],
})
export class AppModule {}
2. Feature Module Registration with McpModule.forFeature
Use McpModule.forFeature in feature modules to register additional resolvers, tools, or resources. This is useful for organizing large servers into multiple modules.
import { Module } from '@nestjs/common';
import { McpModule } from '@nestjs-mcp/server';
import { ToolsResolver } from './tools.resolver';
@Module({
imports: [McpModule.forFeature()],
providers: [ToolsResolver],
})
export class ToolsModule {}
- Use
forRootorforRootAsynconly once in your root module (AppModule). - Use
forFeaturein any feature module where you define MCP capabilities (@Resolverclasses). - Ensure all Resolvers are listed in the
providersarray of their respective modules.
Capabilities
This library provides a set of decorators to define MCP capabilities and apply cross-cutting concerns such as guards. Decorators can be used at both the Resolver (class) level and the method level.
Resolver Decorator
A Resolver is a class that groups related MCP capabilities. All MCP capability methods (@Prompt, @Resource, @Tool) must belong to a class decorated with @Resolver.
- No
@Injectable()Needed: Resolver classes are automatically treated as providers by the MCP module and do not require the@Injectable()decorator. - Dependency Injection: Standard NestJS dependency injection works within Resolver constructors.
- Namespacing: You can optionally provide a string argument to
@Resolver('my_namespace')to namespace the capabilities within that resolver. - Access defaults:
@Resolver({ name, scopes, public, hideOutOfScope })sets defaults for every capability of the class — see Resolver defaults. - Guards: Guards can be applied at the class level using
@UseGuards().
Example:
import { Resolver, Prompt, Resource, Tool } from '@nestjs-mcp/server';
// Import any services you need to inject
import { SomeService } from '../some.service';
@Resolver('workspace') // No @Injectable()
export class MyResolver {
// Inject dependencies as usual
constructor(private readonly someService: SomeService) {}
@Prompt({ name: 'greet_user' }) // Capabilities must be inside a Resolver
greetPrompt(/*...args...*/) {
const greeting = this.someService.getGreeting();
/* ... */
}
@Resource({ name: 'user_profile', uri: 'user://{id}' })
getUserResource(/*...args...*/) {
/* ... */
}
@Tool({ name: 'calculate_sum' })
sumTool(/*...args...*/) {
/* ... */
}
}
You can also apply guards at the resolver level:
import { UseGuards, Resolver } from '@nestjs-mcp/server';
import { MyGuard } from './guards/my.guard';
@UseGuards(MyGuard) // Applied to all capabilities in this Resolver
@Resolver('secure') // No @Injectable()
export class SecureResolver {
// All capabilities in this resolver will use MyGuard
}
Prompt Decorator
Decorate methods within a Resolver class to expose them as MCP Prompts. Accepts options compatible with server.prompt() from @modelcontextprotocol/sdk. The name should use snake_case.
import { Prompt, Resolver } from '@nestjs-mcp/server';
import { McpContext } from '@nestjs-mcp/server'; // Handler context type
import { z } from 'zod'; // Example if using Zod schema
// Optional: Define schema if needed
// const SummaryArgs = z.object({ topic: z.string() });
@Resolver('prompts') // Must be in a Resolver class
export class MyPrompts {
@Prompt({
name: 'generate_summary',
description: 'Generates a summary for the given text.',
// argsSchema: SummaryArgs
})
generateSummaryPrompt(
// params: z.infer<typeof SummaryArgs>, // Arguments based on argsSchema (if defined)
ctx: McpContext, // The request this call arrived on
) {
console.log(`Generating summary for ${ctx.mcpReq.method}`);
/* ... return CallPromptResult ... */
return { content: [{ type: 'text', text: 'Summary generated.' }] };
}
}
Resource Decorator
Decorate methods within a Resolver class to expose them as MCP Resources. Accepts options compatible with server.resource() from @modelcontextprotocol/sdk. The name should use snake_case.
import { Resource, Resolver } from '@nestjs-mcp/server';
import { McpContext } from '@nestjs-mcp/server'; // Handler context type
import { URL } from 'url'; // Type for URI resource
import { z } from 'zod'; // Example if using Zod template
// Optional: Define template schema if needed
// const DocQueryTemplate = z.object({ query: z.string() });
@Resolver('data') // Must be in a Resolver class
export class MyResources {
@Resource({
name: 'user_profile',
uri: 'user://profiles/{userId}',
// metadata: { description: '...' } // Optional
})
getUserProfile(
uri: URL, // First argument is the parsed URI
// metadata: Record<string, any> // Second argument if is defined
ctx: McpContext, // The request this call arrived on
) {
const userId = uri.pathname.split('/').pop(); // Example: Extract ID from URI
console.log(`Fetching profile for ${userId}`);
/* ... return CallResourceResult ... */
return { content: [{ type: 'text', text: `Profile data for ${userId}` }] };
}
@Resource({
name: 'document_list',
template: { type: 'string', description: 'Document content query' }, // Simple template example
// metadata: { list: true } // Optional
})
findDocuments(
uri: URL, // First arg based on simple template type
variables: Record<string, string>, // Second arg is path params (if any)
ctx: McpContext, // The request this call arrived on
) {
console.log(`Finding documents matching '${query}'`);
/* ... return CallResourceResult ... */
return { content: [{ type: 'text', text: 'List of documents.' }] };
}
}
Tool Decorator
Decorate methods within a Resolver class to expose them as MCP Tools. Accepts options compatible with server.tool() from @modelcontextprotocol/sdk. The name should use snake_case.
import { Tool, Resolver } from '@nestjs-mcp/server';
import { McpContext } from '@nestjs-mcp/server';
import { z } from 'zod';
import { CallToolResult } from '@modelcontextprotocol/sdk/types';
@Resolver('user_tools')
export class UserToolsResolver {
@Tool({
name: 'delete_user',
description: 'Deletes a user by ID',
paramsSchema: z.object({ userId: z.string() }),
annotations: { destructiveHint: true, readOnlyHint: false },
})
deleteUser({ userId }: { userId: string }, ctx: McpContext): CallToolResult {
// ...logic...
return { content: [{ type: 'text', text: `User ${userId} deleted.` }] };
}
}
Tool Annotations
The annotations field allows you to provide protocol-level hints about the tool's behavior, such as whether it is destructive, read-only, idempotent, or has other special properties. These hints can be used by clients, UIs, or the protocol itself to display warnings, optimize calls, or enforce policies.
Common annotation keys:
destructiveHint(boolean): Indicates the tool performs a destructive action (e.g., deletes data).readOnlyHint(boolean): Indicates the tool does not modify any data.idempotentHint(boolean): Indicates the tool can be safely called multiple times with the same effect.openWorldHint(boolean): Indicates the tool may have side effects outside the current system.
Example:
@Tool({
name: 'reset_password',
paramsSchema: z.object({ userId: z.string() }),
annotations: { destructiveHint: true, idempotentHint: false }
})
resetPassword({ userId }: { userId: string }): CallToolResult {
// ...
}
ToolOptions Variants
| Variant | Required Fields |
|---|---|
| ToolBaseOptions | name |
| ToolWithDescriptionOptions | name, description |
| ToolWithParamOrAnnotationsOptions | name, paramsSchemaOrAnnotations |
| ToolWithParamOrAnnotationsAndDescriptionOptions | name, paramsSchemaOrAnnotations, description |
| ToolWithParamAndAnnotationsOptions | name, paramsSchema, annotations |
| ToolWithParamAndAnnotationsAndDescriptionOptions | name, paramsSchema, annotations, description |
paramsSchemaandparamsSchemaOrAnnotationscan be a Zod schema for input validation.annotationsis an object with protocol-level hints as described above.
McpContext Argument
All MCP capability methods (@Prompt, @Resource, @Tool) receive an
McpContext object as their last parameter. It extends the SDK's own
ServerContext with the Express request the call arrived on.
Renamed in 2.0. This was
RequestHandlerExtra, a type the MCP SDK removed in v2. See Migrating from1.xto2.x.
Properties from the SDK:
mcpReq.id— the JSON-RPC id of this requestmcpReq.method— the method being served, e.g.tools/callmcpReq._meta/mcpReq.envelope— request metadata and the 2026-07-28 envelopehttp.authInfo— validated access-token information, when auth middleware ransessionId—undefinedon 2026-07-28 traffic. Protocol revision 2026-07-28 retired sessions. It may still be populated for 2025-era clients served through the legacy fallback. Never branch on it for authorization.
Added by @nestjs-mcp/server:
request— the live Express request for this callheaders— shorthand forrequest.headersreportProgress(progress, total?, message?)— sends a progress update to the caller; see Reporting progress
Usage Example:
import { McpContext, Resolver, Tool } from '@nestjs-mcp/server';
import { CallToolResult } from '@modelcontextprotocol/server';
import { z } from 'zod';
@Resolver('auth')
export class AuthResolver {
@Tool({
name: 'authenticate_user',
description: 'Authenticates a user with credentials',
paramsSchema: z.object({ username: z.string(), password: z.string() }),
})
authenticateUser(
params: { username: string; password: string },
ctx: McpContext, // Always the last parameter
): CallToolResult {
// Headers of THIS call, not of the connection handshake
const authHeader = ctx.headers.authorization;
const userAgent = ctx.headers['user-agent'];
console.log(`Serving ${ctx.mcpReq.method} for: ${String(userAgent)}`);
// Anything Nest middleware attached upstream is reachable too
console.log(ctx.request.ip);
return {
content: [{ type: 'text', text: 'Authentication successful' }],
};
}
}
Important Notes:
ctxis always the last parameter in any method decorated with@Resource,@Prompt, or@Toolheaderschanged meaning in 2.0. Before 2.0 they were the headers of the request that opened the connection — theinitializePOST — frozen for its lifetime. They are now this call's own headers, so credentials that expire or are revoked mid-conversation are seen correctly. Code that compiled against 1.x keeps compiling and starts receiving a different (correct) value.ctxis not JSON-serializable:requestis an Express object with circular references, soJSON.stringify(ctx)throws. Read the fields you need.
Reporting progress
A long-running tool, prompt or resource can tell the caller how far along it is:
@Tool({
name: 'analyze_files',
paramsSchema: z.object({ files: z.array(z.string()) }),
})
async analyzeFiles(
params: { files: string[] },
ctx: McpContext,
): Promise<CallToolResult> {
const total = params.files.length;
for (let i = 0; i < total; i++) {
await this.analyzer.analyze(params.files[i]);
await ctx.reportProgress(i + 1, total, `analyzed ${i + 1}/${total}`);
}
return { content: [{ type: 'text', text: `analyzed ${total} files` }] };
}
reportProgress sends a notifications/progress correlated with the
progressToken the client put in the request's _meta:
- No token, no-op. A client that did not ask for progress (most do not) gets nothing, and the call answers exactly as it would have without it.
- Never rejects. An update that can no longer be delivered — the exchange
already ended — is logged at
debugand dropped.void ctx.reportProgress(…)is safe. - Safe to detach. It never reads
this, so it can be passed to a service as a plain callback:await importer.run(rows, ctx.reportProgress). - Whether it is delivered depends on
responseMode— see Progress andresponseMode. - No validation or throttling: keep
progressincreasing, and keep the update rate sensible.
⚠️
messagereaches the caller verbatim. Do not put secrets, credentials or other tenants' data in progress messages.
Dynamic Capabilities
@Tool, @Prompt and @Resource all accept an optional enabled option that decides, per request, whether that capability is advertised and invocable.
type McpCapabilityToggle = boolean | Type<McpCapabilityGate>;
interface McpCapabilityGate {
isEnabled(context: McpRegistrationContext): boolean | Promise<boolean>;
}
interface McpRegistrationContext {
/** The HTTP request whose capability set is being assembled. */
request: Request; // express
/** The effective AuthInfo produced by `auth.strategies` / `auth.authorizers`. */
authInfo?: AuthInfo; // import type { AuthInfo } from '@nestjs-mcp/server'
/** 'modern' (2026-07-28) or 'legacy' (2025-era clients). */
era: ProtocolEra;
}
Two forms, one option key. Omitting enabled is the default and behaves exactly as it did before the option existed: always registered, always enabled, and it costs the request nothing.
Static toggle
For a capability that is simply off. It needs no class, no provider and no await.
@Tool({
name: 'legacy_tool',
description: 'Kept for compatibility, not offered to clients',
enabled: false,
})
legacyTool(): CallToolResult {
/* ... */
}
Capability gate
For a decision the server has to make. A gate is a class, resolved from the Nest container exactly as a guard is — so it can inject services — and its verdict is awaited, so it can do real asynchronous work.
import { Injectable } from '@nestjs/common';
import {
McpCapabilityGate,
McpRegistrationContext,
Resolver,
Tool,
} from '@nestjs-mcp/server';
@Injectable()
export class AdminGate implements McpCapabilityGate {
constructor(private readonly permissions: PermissionsService) {}
// `authInfo` may be undefined — always optional-chain it.
async isEnabled(context: McpRegistrationContext): Promise<boolean> {
if (context.request.headers['x-role'] === 'admin') return true;
return this.permissions.isAdmin(context.authInfo?.clientId);
}
}
@Resolver('admin')
export class AdminResolver {
@Tool({
name: 'rotate_keys',
description: 'Only advertised to admin connections',
enabled: AdminGate,
})
rotateKeys(): CallToolResult {
/* ... */
}
}
Register the gate as a provider in the module that owns the resolver:
@Module({
imports: [McpModule.forRoot({ name: 'admin', version: '1.0.0' })],
providers: [PermissionsService, AdminGate, AdminResolver],
})
export class AppModule {}
Pass the class, never an instance — an instance built at module scope cannot reach the container, which is the whole reason the gate is a class. A gate may also answer synchronously (isEnabled(): boolean) when the check is a cheap in-memory one; boolean | Promise<boolean> covers both.
Note that McpRegistrationContext carries no sessionId, deliberately. It exists under SSE at registration time but not under streamable HTTP, where the transport assigns it while handling the initialize POST — after registration has already run. Exposing it would make the same gate behave differently per transport.
What the client sees
A disabled capability is registered and then disabled, never skipped. It is therefore absent from tools/list / prompts/list / resources/list, and invoking it answers Tool <name> disabled rather than Tool <name> not found — the two are distinct in the SDK and the first is the correct answer for a capability that exists but is off for you.
Fail-closed, in five cases
A gate that cannot answer never leaves a capability exposed:
| Case | Result |
|---|---|
isEnabled throws synchronously |
disabled, error logged |
isEnabled returns a rejecting promise |
disabled, error logged |
| the gate class cannot be resolved from the container | disabled, error logged — it is never silently built with new, because such a gate has undefined dependencies and could answer something accidentally truthy |
| a gate is declared but no registration context exists | disabled, error logged |
disable() itself throws |
the capability remains enabled, and the log says so — the one residual fail-open, because there is no other way to disable |
A failing gate never affects the capabilities beside it, and never turns the request into a 500. Log lines name the capability and the failure only — never the request, its headers, or a token.
Rules you must know before using this
- Evaluated once per request. A fresh MCP server is built for every HTTP request — that is what makes the server stateless — and
enabledis resolved while it is being populated. Changing whatever the gate reads takes effect on the client's very next call; no reconnect is needed. (Before 2.0 the verdict was frozen for the life of a connection.) - No
listChangednotification is emitted. Every toggle is applied before the server is connected to its transport, where the SDK gates notification dispatch. This library never sendsnotifications/tools/list_changedor its prompt/resource equivalents. Do not expect a connected client to be told that something changed — nothing changes mid-session. - Keep gates cheap — they are on the request path. Every
tools/listand everytools/callsettles every gate before the client is served, so a gate that queries a database is a per-request database query. This is the most important behavioural change in 2.0 for anyone already using the option. The library bounds what it can: registration stays synchronous, all gates settle in one concurrency wave (the added latency is the slowest single gate, not the sum), and each distinct gate class is both resolved from the container and asked once per request however many capabilities share it. What it cannot bound is the gate itself — do your caching in the injected service, at provider scope. There is no built-in timeout: a gate that never settles leaves the request unanswered. - A gate with no registration context fails closed. Both built-in transports always pass a context. If you call
RegistryService.registerAll(server)yourself with a single argument, any capability declaring a gate is disabled and the reason is logged. Statictrue/falseand capabilities with noenabledoption are unaffected. authInfomay beundefined. It is only populated when anauthstrategy authenticated the caller (see Authentication & authorization), or when middleware upstream setreq.auth. Writecontext.authInfo?.scopes— a gate that dereferences it unguarded throws, and then fails closed, which silently removes the capability.- Request-scoped gate providers are not supported. A gate is resolved with
moduleRef.get(..., { strict: false }), falling back tomoduleRef.create. Neither handles a request-scoped provider cleanly. Use a singleton gate that reads what it needs from the registration context.
This is not a replacement for guards
enabled is discovery-level defence-in-depth, not the authorization mechanism. Guards are: they run on every capability invocation, against that invocation's own context.
The reason changed in 2.0, and the conclusion survives it. Before 2.0 the argument was mechanical: the registration context was built from the initialize POST and nothing else, so later calls were never re-examined. That is no longer true — a gate now sees each call's own request and req.auth. What remains is the difference in kind: a gate omits a capability from a listing, while a guard refuses an invocation. Omission is not refusal. A client that already knows a tool's name can call it without ever reading the list, and only a guard stops that.
This is the same line the MCP SDK draws for its own per-request factory: the HTTP layer verifies the bearer token, while per-tool checks live in the handler, because "the handler is the authoritative source for the executing tool."
Use enabled to decide what a caller should even see; use a guard to decide whether a call is allowed. Both now see the same request, so a predicate can be factored between them — but hiding a capability is not enforcement: a client that already knows the tool name can still call it, and only a guard stops that.
If you call RegistryService directly
RegistryService.registerAll is async and its second argument — the
registration context — is required:
await registry.registerAll(server, { request, era: 'modern' });
Only code that drives the registry itself is affected; using McpModule
normally is not. See Migrating from 1.x to 2.x
for the full 2.0 change list.
A runnable example lives in
examples/dynamic/—EXAMPLE=dynamic pnpm start:example.
Guards
Apply one or more guards to a Resolver, to individual methods, or globally. Guards must implement the NestJS CanActivate interface.
Global-level guards
This approach uses the standard NestJS global guard system (APP_GUARD). A global guard will protect all NestJS routes, including the MCP endpoint (/mcp). Use this for broad authentication or checks that apply before any MCP-specific logic runs.
// src/guards/global-auth.guard.ts
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Request } from 'express';
@Injectable()
export class GlobalAuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<Request>();
const apiKey = request.headers['x-api-key'];
// Example: Check for a valid API key
return !!apiKey && apiKey === 'EXPECTED_KEY';
}
}
Register the guard globally in your main module:
// src/app.module.ts
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { McpModule } from '@nestjs-mcp/server';
import { GlobalAuthGuard } from './guards/global-auth.guard';
@Module({
imports: [McpModule.forRoot(/*...*/)],
providers: [
{
provide: APP_GUARD,
useClass: GlobalAuthGuard,
},
],
})
export class AppModule {}
Guards attached with @UseGuards receive an McpExecutionContext, not Nest's
ExecutionContext. Implement McpGuard (optionally McpGuard<YourExtra> for a
typed identity) rather than CanActivate: CanActivate types its parameter as
ExecutionContext, so implements CanActivate with an McpExecutionContext
parameter does not compile under strict mode. @UseGuards accepts both. Global
APP_GUARD guards are ordinary Nest guards and keep using CanActivate.
Resolver-level guards
This is a custom feature of this library. Resolver-level guards are applied using the @UseGuards() decorator (exported from @nestjs-mcp/server) on a Resolver class. All MCP methods (@Prompt, @Resource, @Tool) within that specific resolver will be protected by these guards. Use this to enforce logic (e.g., role checks) for a group of related capabilities.
import { UseGuards, Resolver, Prompt } from '@nestjs-mcp/server';
import { RoleGuard } from './guards/role.guard';
@UseGuards(RoleGuard)
@Resolver('admin')
export class AdminResolver {
@Prompt({ name: 'admin_action' })
adminAction(/*...*/) {
/* ... */
}
// ... other admin capabilities
}
Method-level guards
This is a custom feature of this library. Method-level guards are applied using the @UseGuards() decorator directly on an MCP capability method (@Prompt, @Resource, @Tool). Only the decorated method will be protected by these guards. Use this for fine-grained access control on specific capabilities.
import { UseGuards, Resolver, Prompt, Tool } from '@nestjs-mcp/server';
import { SpecificCheckGuard } from './guards/specific-check.guard';
@Resolver('mixed')
export class MixedResolver {
@Prompt({ name: 'public_prompt' })
publicPrompt() {
/* Publicly accessible */
}
@UseGuards(SpecificCheckGuard)
@Tool({ name: 'protected_tool' })
protectedTool(/*...*/) {
/* Requires SpecificCheckGuard to pass */
}
}
Important: Resolver and Method-level guards only run for MCP capability invocations, not for the initial connection establishment handled by global guards. They use the custom McpExecutionContext.
Guard Example
A guard for Resolver or Method-level protection:
// src/guards/my-mcp.guard.ts
import { Injectable } from '@nestjs/common';
import { McpExecutionContext, McpGuard } from '@nestjs-mcp/server';
@Injectable()
export class MyMcpGuard implements McpGuard {
canActivate(context: McpExecutionContext): boolean {
const request = context.getRequest();
const userAgent = request.headers['user-agent'];
const handlerArgs = context.getArgs();
console.log(
`Guard activated for ${context.getContext().mcpReq.method} from ${String(userAgent)}`,
);
console.log('Handler args:', handlerArgs);
return true;
}
}
MCP Execution Context
When implementing Resolver-level or Method-level guards using
@UseGuards() from this library, your canActivate method receives an
McpExecutionContext. It provides access to MCP-specific information:
import { Injectable } from '@nestjs/common';
import { McpExecutionContext, McpGuard } from '@nestjs-mcp/server';
@Injectable()
export class McpAuthGuard implements McpGuard {
canActivate(context: McpExecutionContext): boolean {
// The request this capability was INVOKED on — not the connection
// handshake, as it was before 2.0.
const request = context.getRequest();
const authHeader = request.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
console.log('Guard Denied: Missing or invalid Bearer token.');
return false;
}
const token = authHeader.split(' ')[1];
return token === 'VALID_TOKEN';
}
}
Key points for McpExecutionContext:
getRequest()— the Express request this capability was invoked on. Since 2.0 it is the call's own request, so expiring or revoked credentials are seen correctly. In 1.x it was the connection handshake, frozen for the connection's lifetime.getContext()— the fullMcpContext:mcpReq(id, method,_meta, envelope) andhttp.authInfo.getArgs()— the arguments passed to the MCP handler being invoked. Their shape depends on the capability type; narrow on thetypediscriminator.getClass()/getHandler()— the resolver class and method.getAuthInfo?.()— the effectiveAuthInfo(afterauth.authorizers), orundefinedfor an anonymous request. Optional on the interface so existing test doubles keep compiling.getSessionId()was removed in 2.0. Protocol revision 2026-07-28 retired sessions, so there is no id to return and no store to look one up in.- This is not Nest's
ExecutionContext: there is noswitchToHttp(). UsegetRequest()directly.
Guards with Dependency Injection
Guards can inject NestJS providers. Use @Injectable() and register the guard
as a provider:
@Injectable()
export class AuthGuard implements McpGuard {
constructor(private readonly tokens: TokenService) {}
async canActivate(context: McpExecutionContext): Promise<boolean> {
const header = context.getRequest().headers.authorization;
if (!header) return false;
// Revocation works now: this is the token sent with THIS call.
return this.tokens.isValid(header.replace('Bearer ', ''));
}
}
@Module({
imports: [McpModule.forRoot({ name: 'my-server', version: '1.0.0' })],
providers: [AuthGuard, TokenService, MyResolver],
})
export class AppModule {}
A guard class the container cannot resolve or create denies the call and logs an error (since 2.1). Register guards with dependencies as providers.
Authentication & authorization
Bring your own authentication. The library does not decide how callers
authenticate, nor where the credential travels. Your application registers
authentication strategies — an API key in the header, query parameter or
cookie of your choice, Basic auth, a session cookie, a client certificate, a
JWT, an opaque OAuth token: anything that can be read from the HTTP request.
The library provides the infrastructure around them: it runs them before the
MCP handler, writes the 401/403/429 answers with the challenges your
strategies declare, carries the resulting identity to authorizers, gates,
guards and handlers, and — only if you use OAuth — serves the discovery
metadata. Authorization is a separate layer you own: request authorizers,
per-capability scopes, gates and guards.
HTTP request ─► Layer 1 authentication (strategies, once per request) ─► req.auth: AuthInfo | 401
─► Layer 2a request authorization (authorizers, once per request) ─► req.auth narrowed | 403
─► Layer 2b capability authorization (scopes, gates, guards) ─► handler | 403 / hidden / denied
Evaluation order for one request: strategies → authorizers → gates (enabled,
at registration) → the SDK's scopes challenge → guards (@UseGuards) →
handler. Absent auth, the endpoint behaves exactly as before.
Configuring auth
import { Module } from '@nestjs/common';
import { McpModule } from '@nestjs-mcp/server';
@Module({
imports: [
McpModule.forRoot({
name: 'notes',
version: '1.0.0',
auth: {
strategies: [ApiKeyStrategy, JwtStrategy], // in order; first AuthInfo wins
authorizers: [WorkspaceAuthorizer], // optional, see below
optional: false, // default: unrecognized caller → 401
hideOutOfScope: false, // default: see "Per-capability scopes"
protectedResource: {
// optional: OAuth discovery, see below
resource: 'https://notes.example.com/mcp',
authorizationServers: ['https://auth.example.com'],
scopesSupported: ['notes:read', 'notes:write'],
resourceName: 'Notes',
},
},
}),
],
// Strategies and authorizers are ordinary providers, so they can inject
// services. They may be declared in any module.
providers: [ApiKeyStrategy, JwtStrategy, WorkspaceAuthorizer, NotesResolver],
})
export class AppModule {}
Every strategy and authorizer is resolved from the container at boot. A class
that cannot be resolved stops the application from starting — the library
never falls back to new, which would bypass DI.
With forRootAsync, declare the classes in providers next to the module, and
pass the static protectedResourceMetadata: true flag if the factory returns a
protectedResource (Nest fixes controllers before the factory runs):
McpModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
providers: [JwtStrategy],
protectedResourceMetadata: true,
useFactory: (config: ConfigService) => ({
name: 'notes',
version: '1.0.0',
auth: {
strategies: [JwtStrategy],
protectedResource: {
resource: config.getOrThrow('MCP_RESOURCE'),
authorizationServers: [config.getOrThrow('OAUTH_ISSUER')],
},
},
}),
});
Writing a strategy
A strategy returns the SDK's AuthInfo (re-exported from this package), null
("no credentials for me, try the next one"), or throws:
| Outcome | HTTP answer |
|---|---|
returns AuthInfo |
continues; later strategies are skipped |
every strategy returns null |
401 + WWW-Authenticate with every registered challenge (no error=) |
throw new McpUnauthorizedError(description) |
401 {"error":"unauthorized"} + the registered challenges; stops |
throw new OAuthError(OAuthErrorCode.InvalidToken) |
401 + Bearer error="invalid_token" challenge (OAuth strategies); stops |
throw new McpHttpError(status, body, headers) |
written as-is (e.g. 429 with Retry-After) |
| anything else | 500, logged without headers or token |
AuthInfo is the MCP SDK's identity type (it is what handlers receive as
ctx.http.authInfo), whatever the method: clientId is the id of the
authenticated principal and token the credential that was presented. Both are
required, and expiresAt is in seconds since the epoch. A returned
AuthInfo without a string token, a string clientId and a scopes array of
strings, or with an expiresAt that is clearly milliseconds (above 1e11), is
a bug answered 500; one whose expiresAt has already passed is answered 401 invalid_token. Put application
identity (user id, key id, workspace) in AuthInfo.extra. CORS preflight
(OPTIONS) is never authenticated. Never put the credential in an error
message: an unexpected error is logged by its class and message.
Challenges. A strategy may declare readonly challenge — the RFC 9110
challenge that tells clients how to present its credential, e.g.
ApiKey header="x-api-key" or Basic realm="notes". Every 401 lists all
declared challenges; when protectedResource is set, the Bearer challenge with
resource_metadata comes first (MCP clients look for it). With no declared
challenge and no protectedResource, the header is a bare Bearer. A
challenge that is not one valid RFC 9110 challenge stops the application at
boot, and so does a scopesSupported or decorator scopes entry that is not an
OAuth scope-token (no spaces, quotes, backslashes or control characters).
API key from a header you choose (not OAuth; no OAuth vocabulary needed):
import { Injectable } from '@nestjs/common';
import {
AuthenticatedRequest,
AuthInfo,
McpAuthStrategy,
McpUnauthorizedError,
} from '@nestjs-mcp/server';
@Injectable()
export class ApiKeyStrategy implements McpAuthStrategy {
readonly challenge = 'ApiKey header="x-api-key"';
constructor(private readonly keys: ApiKeyService) {}
async authenticate(req: AuthenticatedRequest): Promise<AuthInfo | null> {
const key = req.headers['x-api-key'];
if (typeof key !== 'string') return null; // not mine
const record = await this.keys.find(key);
if (!record) throw new McpUnauthorizedError('Unknown API key');
return { token: key, clientId: record.id, scopes: record.scopes };
}
}
The credential's location is entirely the strategy's decision: read
req.query.api_key, req.cookies.session, req.socket.getPeerCertificate()
or any header. Prefer headers: query strings end up in access logs and proxies,
and the MCP authorization spec forbids them for OAuth access tokens.
API key via Bearer is the same strategy reading Authorization. Return
null for a Bearer value that is not one of your keys (for example, one with a
JWT's xxx.yyy.zzz shape) so a JWT strategy later in the list still gets it.
JWT with jose — checking the audience is
the strategy's job (RFC 8707): a token minted for another resource must not be
accepted.
import { Injectable } from '@nestjs/common';
import { createRemoteJWKSet, jwtVerify } from 'jose';
import {
AuthenticatedRequest,
AuthInfo,
McpAuthStrategy,
OAuthError,
OAuthErrorCode,
} from '@nestjs-mcp/server';
const JWKS = createRemoteJWKSet(
new URL('https://auth.example.com/.well-known/jwks.json'),
);
@Injectable()
export class JwtStrategy implements McpAuthStrategy {
async authenticate(req: AuthenticatedRequest): Promise<AuthInfo | null> {
// The auth-scheme is case-insensitive (RFC 9110 §11.1).
const match = /^Bearer +(\S+)$/i.exec(req.headers.authorization ?? '');
if (!match) return null;
const token = match[1];
try {
const { payload } = await jwtVerify(token, JWKS, {
issuer: 'https://auth.example.com',
audience: 'https://notes.example.com/mcp',
requiredClaims: ['exp'], // a token without expiry never stops working
});
const clientId = [payload.client_id, payload.sub].find(
(claim): claim is string => typeof claim === 'string',
);
if (!clientId) {
throw new OAuthError(OAuthErrorCode.InvalidToken, 'No client id');
}
return {
token,
clientId,
scopes: String(payload.scope ?? '')
.split(' ')
.filter(Boolean),
expiresAt: payload.exp, // seconds
extra: { sub: payload.sub },
};
} catch {
throw new OAuthError(OAuthErrorCode.InvalidToken, 'Invalid access token');
}
}
}
OAuth discovery (protectedResource)
With protectedResource set, the library serves the RFC 9728 Protected Resource
Metadata document at the path derived from resource
(https://notes.example.com/mcp → /.well-known/oauth-protected-resource/mcp)
and advertises it as resource_metadata on the challenges the library builds:
the 401 for missing or invalid_token credentials and the 403
insufficient_scope step-up. An McpAccessDeniedError, an McpHttpError, or
an McpUnauthorizedError carrying its own challenge is written as thrown. That is
how OAuth-capable MCP clients (claude.ai, ChatGPT) find your authorization
server. The library does not serve authorization-server metadata — that
belongs to the AS.
Trying OAuth end to end
examples/oauth runs an MCP server protected by OAuth 2.1
next to a mock authorization server that behaves like a real provider, with no
Docker and no provider account. How to run it, the demo users and clients, the
failure scenarios and how to swap the mock for Auth0, Okta or Keycloak are in
the example's README.
Request authorizers (Layer 2a)
An McpAuthorizer runs once per request after authentication, receives the
AuthInfo so far and returns the one to continue with. Use it for anything that
changes the caller's effective scopes — it must run before the SDK sees the
request, because the SDK evaluates scopes before any guard:
@Injectable()
export class WorkspaceAuthorizer implements McpAuthorizer {
constructor(private readonly members: MembershipService) {}
async authorize(
req: AuthenticatedRequest,
auth: AuthInfo,
): Promise<AuthInfo> {
const workspace = String(req.headers['x-workspace']);
const role = await this.members.roleOf(auth.clientId, workspace);
if (!role) throw new McpAccessDeniedError('Not a member of this workspace'); // 403 access_denied
return {
...auth,
scopes: auth.scopes.filter((scope) => role.allows(scope)), // key ∩ role
extra: { ...auth.extra, workspace },
};
}
}
Throw McpAccessDeniedError for 403 { "error": "access_denied" } (nothing a
re-consent could fix), or new OAuthError(OAuthErrorCode.InsufficientScope, …)
for a 403 step-up challenge. The returned AuthInfo is what gates,
scopes, guards (context.getAuthInfo()) and handlers (ctx.http.authInfo)
see — both read it with getAuthInfo(ctx).
Per-capability scopes (Layer 2b)
@Tool, @Resource and @Prompt accept scopes:
@Tool({ name: 'add_note', paramsSchema: AddNote, scopes: ['notes:write'] })
The SDK checks them before dispatch against the effective AuthInfo.
auth.scopeSatisfies(granted, required) overrides the default check (every
required scope granted). What happens when they are not held depends on
hideOutOfScope:
hideOutOfScope |
Listed? | Calling it | Use when |
|---|---|---|---|
false (default) |
yes | 403 insufficient_scope, scope="<required scopes>" |
the grant lacks a scope the user can consent to (OAuth step-up) |
true |
no | "disabled" | the narrowing comes from something re-consent cannot fix, e.g. a workspace role |
A request without AuthInfo is not challenged when auth is not configured
(matching the SDK's requireScopes). When auth is configured with
optional: true, an anonymous request finds scoped capabilities disabled.
With auth configured, tools/list, prompts/list, resources/list and
resources/templates/list default to private, zero-TTL cache hints, since the
lists are per caller. Explicit server.cacheHints win.
Resolver defaults
@Resolver also accepts an options object whose scopes, public and
hideOutOfScope become the defaults for every capability of the class. The
string form, @Resolver('notes'), is unchanged.
@Resolver({ name: 'notes', scopes: ['notes:read'] })
export class NotesResolver {
@Tool({ name: 'list_notes' }) // requires notes:read (inherited)
listNotes() {}
@Tool({ name: 'add_note', scopes: ['notes:write'] }) // requires notes:write only
addNote() {}
@Tool({ name: 'about', public: true }) // no scopes: public drops them
about() {}
}
Precedence is capability > resolver > module: a capability's scopes
replace the resolver's (they are not merged), a capability's public
overrides the resolver's, and hideOutOfScope on a capability or resolver
overrides auth.hideOutOfScope. A capability declaring its own scopes inside
a @Resolver({ public: true }) class is protected, not public. Scope tokens
are validated when the class is defined, as on the method decorators.
Public capabilities
public: true on @Tool, @Resource or @Prompt (or on @Resolver) opens a
capability to callers no strategy recognized, while auth stays mandatory
for everything else:
@Tool({ name: 'search_docs', public: true })
searchDocs() {}
When auth is configured without optional: true and every strategy returns
null, the request is normally answered 401. If the module declares at least
one public capability, the already-parsed JSON-RPC body is inspected instead, and
the request is served anonymously only if every message in it (one message
or a batch) is:
- a handshake or liveness message —
initialize,server/discover,ping, anynotifications/*; - a list —
tools/list,prompts/list,resources/list,resources/templates/list— which then lists only public capabilities; - a
tools/call,prompts/getorresources/readnaming a public tool, prompt, resource URI, or a URI matching a public resource template.
Anything else — another method, a non-public target, a missing or malformed
body, a GET — gets the same 401 with the same challenges as before. For an
anonymous request every non-public capability is disabled (unlisted, and
"disabled" if called directly), so nothing but public capabilities is reachable.
- Credentials change nothing. A request with valid credentials sees and calls
capabilities exactly as without
public; public ones are available to everyone. publicandscopesare mutually exclusive. Declaring both on the same capability (or on the same@Resolver) throws when the class is defined: "anyone" and "only a grant holding X" contradict each other, and silently picking one would either expose or lock away something by accident.- No public capability, no change. Without one, the authentication path is exactly the same as before — the body is never inspected.
optional: truekeeps its meaning. Every anonymous request is let through without body inspection, unscoped capabilities stay visible to it, and scoped ones are disabled.- The handshake is public too. With any public capability,
initializeandserver/discover— including the server'sinstructions— are readable anonymously, so keep nothing confidential there. - Both protocol eras. The check runs on the parsed body before the SDK sees
the request, so 2025-era clients (e.g.
@modelcontextprotocol/sdkv1) work anonymously too:initialize→tools/list→ a publictools/call.
Typed identity (getAuthInfo)
getAuthInfo<TExtra>(ctx) returns the effective AuthInfo — what the
strategies produced and the authorizers shaped — from a handler's McpContext
or a guard's McpExecutionContext, with extra typed as TExtra
(McpAuthInfo<TExtra>). It is undefined for an anonymous request.
type WorkspaceExtra = { workspace: string }; // a type alias, not an interface
@Tool({ name: 'list_notes', scopes: ['notes:read'] })
listNotes(ctx: McpContext) {
const workspace = getAuthInfo<WorkspaceExtra>(ctx)?.extra?.workspace;
return this.notes.listFor(workspace); // the service decides which rows
}
@Injectable()
export class OwnerGuard implements McpGuard<WorkspaceExtra> {
canActivate(context: McpExecutionContext<WorkspaceExtra>) {
return getAuthInfo<WorkspaceExtra>(context)?.extra?.workspace !== undefined;
}
}
TExtra is an assertion about what your strategies and authorizers put in
extra, not a runtime check. McpExecutionContext takes the same optional
type parameter; without it, getAuthInfo() returns the SDK's AuthInfo as
before. Use a type alias for TExtra: an interface would need an index
signature, which lets any key through.
Which tool for which job
| Mechanism | Runs | Answers | Use for |
|---|---|---|---|
| strategy | once per request | 401 |
recognizing the caller |
| authorizer | once per request, after authentication | 403 / narrowed AuthInfo |
tenant/workspace membership, role ∩ key scopes |
scopes |
per capability, before dispatch | 403 insufficient_scope / hidden |
OAuth scopes a capability requires |
gate (enabled) |
per capability, at registration | hidden + "disabled" | discovery-level visibility from arbitrary logic |
guard (@UseGuards) |
per invocation, before the handler | tool error | per-call checks on arguments or resources being touched |
Choosing where to authorize
Each layer answers a different question. The library never authorizes data: it decides whether a caller may reach an operation, never which records that operation may touch.
| Where | Answers | Shape | Failure the client sees |
|---|---|---|---|
scopes |
what the credential/app was granted, per operation | declarative, like a permission decorator on a controller method | 403 insufficient_scope (step-up), or hidden |
guards (@UseGuards) |
your application's rules for one operation (arguments, time, feature flags) | code, per invocation | a tool error |
| authorizers | who the caller is for this request: tenant/workspace, role ∩ credential scopes | code, once per request; shapes the AuthInfo everything else reads |
403 access_denied / narrowed scopes |
| application services | data-level authorization — "is this record in this workspace?" — the source of truth | your domain code, called by the handler with getAuthInfo(ctx) |
whatever your handler returns |
public |
which operations need no identity at all | declarative, per capability or resolver | 401 for everything else |
| resolver defaults | the common scopes / public / hideOutOfScope of a group of operations |
declarative, on @Resolver({ … }) |
as scopes / public |
| typed identity | how handlers and guards read the caller | getAuthInfo<TExtra>(ctx) |
— |
Why a scope failure is a 403 but a guard failure is a tool error. scopes
are known before dispatch, so the SDK can refuse the HTTP request itself with a
403 and a WWW-Authenticate challenge an OAuth client knows how to act on
(re-consent with more scopes). A guard runs inside the dispatched call: by then
the MCP request has been accepted and the HTTP response is committed to a
JSON-RPC result, so the only honest answer left is an error result for that
tool call. Put anything a client could fix by re-authorizing in scopes (or an
authorizer); keep guards for rules re-consent cannot change.
Things to know
- Global
APP_GUARDs also run on/mcp. A REST guard registered globally will see MCP traffic too; apply REST guards per controller instead. They run before the MCP controller authenticates the request, soreq.authis stillundefinedthere: authorize MCP callers withauth.authorizers,@UseGuardson resolvers, or capability gates, which all see theAuthInfo. - The MCP endpoint and the metadata route are unversioned.
/mcpand/.well-known/oauth-protected-resource…are servedVERSION_NEUTRAL, so they stay at those paths even when the app enables URI versioning. - Global prefix. If you call
app.setGlobalPrefix(), exclude the MCP routes:app.setGlobalPrefix('api', { exclude: ['mcp', '.well-known/{*path}'] }). - Unresolvable guards now deny (since 2.1). A
@UseGuardsclass the container cannot resolve used to fall back tonew Guard(); it is now a denial plus an error log, matching gates.
See examples/auth for a runnable server with an API-key
strategy, a JWT strategy, a tenant authorizer and scoped tools.
Statelessness
Since 2.0 this library holds no state between requests. There is no session store, no session id, and no sticky-routing requirement.
The SDK's createMcpHandler builds a fresh McpServer for every HTTP
request from a factory this library supplies. That factory runs
RegistryService.registerAll against the request being served, so every
capability handler and every guard closes over that request. Nothing survives
the response.
What this buys you
- Run any number of instances behind a plain round-robin load balancer. No sticky sessions, no shared session store, no session affinity at the gateway.
- Instances can restart or autoscale mid-conversation without breaking clients.
- Deploy to serverless and edge runtimes that cannot hold a connection open.
This is the resolution of
#121, and it
is verified end to end by test/stateless-load-balancing.e2e-spec.ts: two
independently constructed Nest applications behind a strict round-robin proxy,
driven by one MCP client, with an assertion that no Mcp-Session-Id ever
crosses the wire.
Reaching request data
Everything the old SessionManager lookup existed to recover is now handed to
you directly, and it describes the current call rather than the connection
handshake:
// In a capability handler
handler(params: Params, ctx: McpContext) {
ctx.headers.authorization; // this call's credential
ctx.request.ip; // the live Express request
}
// In a guard
canActivate(context: McpExecutionContext) {
context.getRequest().headers.authorization;
}
Transport Options
The library exposes one MCP endpoint: ALL /mcp.
Changed in 2.0. 1.x served
POST/GET/DELETE /mcpplusGET /sseandPOST /messages, with per-transportenabledtoggles. The HTTP+SSE transport was removed: it is structurally sticky-session — a long-livedGET /ssemust be paired withPOST /messageson the same instance — which is exactly the problem statelessness solves.GETandDELETEon/mcpwere the 2025-era session operations and now answer405.
Backward compatibility. 2025-era clients are still served, on the same
endpoint, through the SDK's stateless legacy fallback. Every currently published
MCP client SDK — including @modelcontextprotocol/sdk@1 and
@modelcontextprotocol/client@2 — speaks that era, and both are covered by the
e2e suite.
Options are passed straight through to the SDK's createMcpHandler:
McpModule.forRoot({
name: 'my-server',
version: '1.0.0',
transport: {
// 'stateless' (default) — serve 2025-era clients per request.
// 'reject' — modern-only; refuses 2025-era traffic.
legacy: 'stateless',
// 'auto' (default) — JSON, upgrading to SSE if the handler streams
// 'json' — never stream (drops mid-call notifications)
// 'sse' — always stream
responseMode: 'auto',
// SSE keepalive interval; 0 disables. Default 15000.
keepAliveMs: 15000,
},
});
| Option | Purpose |
|---|---|
legacy |
How 2025-era traffic is handled. Default 'stateless'. |
responseMode |
Response shaping for modern exchanges. Default 'auto'. |
keepAliveMs |
SSE comment-frame keepalive. Default 15000. |
maxSubscriptions |
Cap on open subscriptions/listen streams. Default 1024. |
bus |
Change-event bus for subscriptions/listen. Swap for Redis to fan out notifications across instances. |
Progress and responseMode
Whether ctx.reportProgress reaches the client depends on
the protocol era of the request and on responseMode:
| Request era | 'auto' (default) |
'sse' |
'json' |
|---|---|---|---|
| Modern (2026-07-28) | Delivered | Delivered | Dropped — one warning, see below |
| Legacy (2025, SDK fallback) | Delivered over SSE | Delivered over SSE | Delivered over SSE |
- Modern +
'json'. The SDK drops mid-call notifications in this mode. The first time a handler callsreportProgress, onewarnline is logged under@mcp:progress; the SDK also prints its ownconsole.warnonce at boot for any'json'endpoint. The call itself still succeeds. - Legacy era. The SDK's legacy fallback always answers a call over SSE and
does not apply
responseMode, so progress is delivered in every mode. - The SSE upgrade on
'auto'. Without progress, a modern call answers a singleapplication/jsonbody. After the first progress frame the response is200 text/event-stream— progress frames, then the result. From then on a later error rides inside the stream as a JSON-RPC error, not as an HTTP status: a client that keys on the status code sees200.
Server-wide protocol options live under server, passed verbatim to the SDK:
server.* |
Purpose |
|---|---|
requestState.verify |
Validates the state an MRTR retry echoes — see Resuming on a different instance |
cacheHints |
ttlMs / cacheScope for the server's cacheable methods (tools/list, server/discover, …). Per-resource hints go on @Resource. |
inputRequired |
maxRounds, roundTimeoutMs for MRTR |
jsonSchemaValidator |
Swap the JSON Schema validation engine |
⚠️
legacy: 'reject'refuses every client SDK published today, since none of them speak the 2026-07-28 era yet. Choose it only when you control every caller and they issue raw requests.
MCP 2026-07-28 features
Multi Round-Trip Requests (MRTR)
The 2026-07-28 spec replaced server-initiated elicitation/create and
sampling/createMessage — which required a held-open bidirectional stream, and
therefore a session — with a retry handshake. A handler that needs input returns
inputRequired(...); the client re-sends the same call with the answers
attached, and the handler completes.
import {
acceptedContent,
CallToolResult,
inputRequired,
InputRequiredResult,
McpContext,
Resolver,
Tool,
} from '@nestjs-mcp/server';
@Resolver('ops')
export class OpsResolver {
@Tool({ name: 'deploy', paramsSchema: z.object({ env: z.string() }) })
deploy(
params: { env: string },
ctx: McpContext,
): CallToolResult | InputRequiredResult {
const answer = acceptedContent<{ confirm: boolean }>(
ctx.mcpReq.inputResponses,
'confirm',
);
if (!answer) {
return inputRequired({
inputRequests: {
confirm: inputRequired.elicit({
message: `Deploy to ${params.env}?`,
requestedSchema: {
type: 'object',
properties: { confirm: { type: 'boolean' } },
required: ['confirm'],
},
}),
},
});
}
return { content: [{ type: 'text', text: `deployed to ${params.env}` }] };
}
}
Resuming on a different instance
The handler above re-derives everything from its arguments, so any instance can
serve the retry. When the round trip must carry state, seal it into
requestState — it round-trips through the client, so it is
attacker-controlled input and the SDK applies no integrity protection by
default.
import { createRequestStateCodec } from '@nestjs-mcp/server';
// The HMAC key must be identical on every instance that might receive the
// echoed value. That shared key is what lets the retry land on another pod.
const codec = createRequestStateCodec<{ item: string }>({
key: process.env.MCP_STATE_KEY!, // ≥ 32 bytes
});
McpModule.forRoot({
name: 'shop',
version: '1.0.0',
server: { requestState: { verify: (...a) => codec.verify(...a) } },
});
// in the handler
return inputRequired({
inputRequests: { confirm: inputRequired.elicit({/* … */}) },
requestState: await codec.mint({ item: params.item }),
});
// on the retry, decoded and verified by whichever instance served it
const state = ctx.mcpReq.requestState<{ item: string }>();
Without the verify hook the echoed state is accepted unverified. With it, a
tampered value is rejected — both cases are covered in
test/mcp-features.e2e-spec.ts.
The client must declare
elicitation: { form: {} }in its capabilities on every request, or the server refuses to ask (-32021).
ctx.mcpReq.elicitInput and ctx.mcpReq.requestSampling still exist but are
deprecated and throw on a 2026-07-28 request. MRTR is the only path.
Structured output and display metadata
@Tool({
name: 'measure',
title: 'Measure Something', // display name
paramsSchema: z.object({ subject: z.string() }),
outputSchema: z.object({ subject: z.string(), value: z.number() }),
icons: [{ src: 'https://example.com/icon.png', mimeType: 'image/png' }],
_meta: { 'com.example/category': 'diagnostics' },
})
measure(params: { subject: string }): CallToolResult {
return {
content: [{ type: 'text', text: `measured ${params.subject}` }],
structuredContent: { subject: params.subject, value: 42 },
};
}
title, icons and _meta are also available on @Prompt.
Resource cache hints
@Resource({
name: 'catalog',
uri: 'catalog://items',
cacheHint: { ttlMs: 60_000, cacheScope: 'public' },
})
ttlMs and cacheScope ride on the resources/read result so clients can
cache instead of re-fetching. Resource-only by design: tools/list and
prompts/list return one result for the whole server, so a per-capability hint
would have nowhere to go — use server.cacheHints for those.
Emitting list_changed
Inject McpHttpService and publish onto its notifier. This is the first release
in which the library can emit change notifications at all.
@Injectable()
export class PermissionsService {
constructor(private readonly mcp: McpHttpService) {}
onRoleChanged(): void {
this.mcp.notify.toolsChanged();
}
}
Notifications reach clients holding a subscriptions/listen stream. In a
multi-instance deployment, supply transport.bus backed by Redis pub/sub so a
change on one instance fans out to subscribers on the others.
Migrating from 1.x to 2.x
2.0 adopts MCP specification 2026-07-28, which retires sessions, and moves to the MCP SDK's v2 package family. Existing MCP clients keep working — they are served through the SDK's stateless legacy fallback — but the server-side API changed.
Install
# 1.x pnpm add @nestjs-mcp/server @modelcontextprotocol/sdk # 2.x — the SDK split into scoped packages; the server package is what you import types from pnpm add @nestjs-mcp/server @modelcontextprotocol/server
The breaking changes
| 1.x | 2.x | Why |
|---|---|---|
RequestHandlerExtra |
McpContext |
The SDK removed the type. ctx.request is now the live Express request. |
extra.sessionId |
(gone) | 2026-07-28 retired sessions. ctx.sessionId exists but is undefined on modern traffic. |
extra.headers |
ctx.headers |
Meaning changed — see below. |
extra.body (undocumented) |
ctx.request.body |
Now declared, and from the invoking request. |
SessionManager |
(gone) | No session store. Use ctx.request or context.getRequest(). |
McpExecutionContext.getSessionId() |
(removed) | Nothing to return. Use getRequest() or getContext(). |
paramsSchema: { a: z.string() } |
paramsSchema: z.object({ a: z.string() }) |
v2 takes Standard Schema; raw Zod shapes are no longer expressible. |
argsSchema: { … } |
argsSchema: z.object({ … }) |
Same. |
transports: { sse, streamable } |
transport: { legacy, responseMode, … } |
One endpoint; options pass through to createMcpHandler. |
session: { sessionTimeoutMs, … } |
(gone) | No sessions to time out. |
GET /sse + POST /messages |
(removed) | HTTP+SSE is structurally sticky-session. |
GET / DELETE /mcp |
405 |
These were session operations. |
McpModule.forFeature() |
still present | Unchanged (still a no-op). |
The silent one — read this
extra.headers compiled in 1.x and compiles in 2.x, but returns something
different.
In 1.x it was the headers of the request that opened the connection — the
initialize POST, or the GET /sse handshake — recovered from the session
store and frozen for the connection's lifetime. In 2.x it is the headers of the
call being served.
This is a bug fix, and it is the point of the release: an Authorization header
that expires or is revoked mid-conversation is now seen. But if you relied on
connection-scoped values, they no longer persist across calls. Mint an explicit
handle from a tool and have the model pass it back as an argument instead.
Handler signature
- import { RequestHandlerExtra, Resolver, Tool } from '@nestjs-mcp/server';
- import { CallToolResult } from '@modelcontextprotocol/sdk/types';
+ import { McpContext, Resolver, Tool } from '@nestjs-mcp/server';
+ import { CallToolResult } from '@modelcontextprotocol/server';
@Resolver('example')
export class ExampleResolver {
@Tool({
name: 'my_tool',
- paramsSchema: { id: z.string() },
+ paramsSchema: z.object({ id: z.string() }),
})
- myTool(params: { id: string }, extra: RequestHandlerExtra): CallToolResult {
- const auth = extra.headers.authorization;
+ myTool(params: { id: string }, ctx: McpContext): CallToolResult {
+ const auth = ctx.headers.authorization;
return { content: [{ type: 'text', text: params.id }] };
}
}
Guards
@Injectable()
export class AuthGuard implements McpGuard {
- constructor(private readonly sessionManager: SessionManager) {}
-
canActivate(context: McpExecutionContext): boolean {
- const session = this.sessionManager.getSession(context.getSessionId());
- return !!session?.request.headers.authorization;
+ return !!context.getRequest().headers.authorization;
}
}
Client compatibility
No client change is required. legacy: 'stateless' is the default and serves
2025-era clients on the same /mcp endpoint. Set transport.legacy = 'reject'
only if you control every caller — no published client SDK speaks the
2026-07-28 era yet.
Inspector Playground
Facts
- Kind
- MCP server
- Group
- Uncategorized
- Stars
- 39
- License
- MIT
- Language
- TypeScript
- Last push
- 2026-10-01
- Forks
- 3
- 1REAio.github.morluto/reaReverse engineer anything from your terminal or agent with one CLI and MCP server.125.3k
- 2com.browser-use/browser-usecom.browser-use/browser-useControl a real Chrome browser to complete any task: fill forms, extract data, book flights.117.7k
- 3World Monitorapp.worldmonitor/mcpLive markets, conflicts, country risk, chokepoints, energy, and China decision signals. 93 tools.88.3k
- 4Scrapling MCP Serverd4vinci/scrapling🕷️ An adaptive Web Scraping framework that handles everything from a single request to a full-scale crawl! Don't be shy, join here: https://discord.gg/EMgGbDceNQ and follow here for daily tips and tricks: https://x.com/Scraplingdev86.9k
- 5Everythingmodelcontextprotocol/serversThis MCP server attempts to exercise all the features of the MCP protocol. It is not intended to be a useful server, but rather a test server for builders of MCP clients. It implements prompts, tools, resources, sampling, and more to showcase MCP capabilities.85.8k
- 6Fetchmodelcontextprotocol/serversA Model Context Protocol server that provides web content fetching capabilities. This server enables LLMs to retrieve and process content from web pages, converting HTML to markdown for easier consumption.85.8k