关于 MCP Server NestJS Module Library
NestJS MCP Server 是一个用于基于 NestJS 构建 MCP 服务的模块化库。它通过装饰器和模块提供 resource、tool 与 prompt 的注册方式,并封装官方 @modelcontextprotocol/sdk。适合需要组织大型 MCP 服务的 NestJS 开发者。
主要功能
- 01基于装饰器注册 Tool、Resource 与 Prompt
- 02Resolver 类分组并支持命名空间
- 03forRoot 与 forFeature 模块化注册
- 04支持 Guard 与依赖注入
- 05提供工具注解(annotations)配置
适用场景
- 01在 NestJS 应用中暴露业务接口为 MCP 工具
- 02将大型 MCP 服务按功能拆分为多个模块
- 03为 MCP 能力添加访问控制与守卫
配置
本地服务的配置在各客户端中通用,复制后粘贴到客户端的 MCP 配置文件即可。
工具
本地服务需要在你的电脑上运行后才能读取工具,维护者暂未提供工具列表。
详细介绍
MCP Server NestJS 模块库
概述
NestJS MCP Server 是一个用于借助 NestJS 构建 Model Context Protocol (MCP) 服务的模块化库。它提供装饰器、模块和集成模式,以可扩展且易于维护的方式提供 MCP 资源、工具和提示词。本项目是官方 @modelcontextprotocol/sdk 的封装,并始终确保与其类型和规范兼容。
目录
- 安装
- 快速入门
- 什么是 MCP?
- 核心概念
- 模块 API
- 模块用法
- 功能
- 动态功能
- 守卫
- 身份验证与授权
- 无状态性
- 传输选项
- MCP 2026-07-28 功能
- 从
1.x迁移到2.x - Inspector 演练场
- 示例
- 更新日志
- 许可证
- 贡献
安装
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
快速入门
在 NestJS 应用中注册 MCP 模块,并公开一个简单的工具:
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 {}
什么是 MCP?
模型上下文协议(MCP) 是一种开放协议,用于将大模型连接到外部数据、工具和提示词。MCP 服务以标准化方式公开资源(数据)、工具(操作)和提示词(对话流程),从而实现与大模型驱动的客户端无缝集成。
- 如需了解更多背景,请参阅 Anthropic 公告。
核心概念
服务端
MCP 服务端是向大模型公开功能的主要入口。它负责管理资源、工具和提示词的注册与发现。
资源
资源表示大模型可以查询或检索的结构化数据或文档。资源通常为只读,并通过唯一 URI 标识。
- 了解更多:MCP 资源文档
工具
工具是 LLM 可以调用的操作或函数。工具可能产生副作用,也可以接受参数来执行计算或触发操作。
- 了解更多:MCP Tools 文档
提示词
提示词定义了适用于 LLM 的对话流程、模板或交互模式。提示词有助于引导模型在特定场景中的行为。
- 了解更多:MCP Prompts 文档
实现详情和代码示例,请参阅功能部分。
模块 API
McpModule.forRoot
在 NestJS 应用中全局注册 MCP 服务。
参数:
options: McpModuleOptions— 主要的服务配置对象:name: string:MCP 服务的名称。version: string:MCP 服务的版本。instructions?: string:可选的 MCP 服务描述,供客户端使用。capabilities?: Record<string, unknown>:可选的其他功能元数据。providers?: Provider[]:要包含在模块中的可选 NestJS 提供者数组。imports?: any[]:要导入的可选 NestJS 模块数组。logging?: McpLoggingOptions:可选的日志配置:enabled?: boolean(默认值:true):启用或禁用日志记录。level?: 'error' | 'warn' | 'log' | 'debug' | 'verbose'(默认值:'verbose'):设置日志级别。
transports?: McpModuleTransportOptions:可选的传输配置(请参阅传输选项)。protocolOptions?: Record<string, unknown>:直接传递给底层@modelcontextprotocol/sdk服务实例的可选参数。
返回值:
- 一个注册了所有 MCP 提供者的动态 NestJS 模块。
示例:
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
使用异步选项全局注册 MCP 服务,适用于与 @nestjs/config 等配置模块集成。
注意:
imports数组应包含为useFactory提供所需依赖项的任何模块(例如,注入ConfigService时需要包含ConfigModule)。- 仅在根模块(
AppModule)中使用一次forRootAsync。- 有关所有可用选项,请参阅
McpModuleAsyncOptions。
参数:
options: McpModuleAsyncOptions— 异步配置对象:imports?: any[]:在工厂运行前要导入的可选模块。useFactory: (...args: any[]) => Promise<McpModuleOptions> | McpModuleOptions:返回McpModuleOptions的工厂函数。inject?: any[]:要注入useFactory的可选提供者。
返回值:
- 一个动态 NestJS 模块。
示例(与 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
在功能模块中注册其他 MCP 资源、工具或提示词。使用此方法可将大型服务组织到多个模块中。包含 MCP 功能的解析器必须加入功能模块的 providers 数组。
参数:
options?: McpFeatureOptions(目前未使用,为未来增强功能保留)。
返回值:
- 一个动态模块。
示例:
// 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 {}
模块用法
此库提供两种在 NestJS 应用中注册 MCP 功能的主要方式:
1. 使用 McpModule.forRoot 进行全局注册
在根应用模块中使用 McpModule.forRoot,以全局配置并注册 MCP 服务。每个 MCP 服务应用都必须进行此操作。
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. 使用 McpModule.forFeature 注册功能模块
在功能模块中使用 McpModule.forFeature 注册额外的解析器、工具或资源。这有助于将大型服务拆分为多个模块。
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 {}
- 在根模块(
AppModule)中,仅使用一次forRoot或forRootAsync。 - 在定义 MCP 功能(
@Resolver类)的任何功能模块中使用forFeature。 - 确保在各自模块的
providers数组中列出所有解析器。
功能
此库提供了一组装饰器,用于定义 MCP 功能并应用守卫等横切关注点。装饰器既可用于解析器(类)级别,也可用于方法级别。
解析器装饰器
解析器是一个用于归类相关 MCP 功能的类。所有 MCP 功能方法(@Prompt、@Resource、@Tool)都必须属于使用 @Resolver 装饰的类。
- **无需使用
@Injectable():**MCP 模块会自动将解析器类视为提供者,无需使用@Injectable()装饰器。 - **依赖注入:**标准的 NestJS 依赖注入可在解析器构造函数中使用。
- **命名空间:**可以选择为
@Resolver('my_namespace')提供字符串参数,以设置该解析器中各项功能的命名空间。 - 访问默认值:
@Resolver({ name, scopes, public, hideOutOfScope })会为该类的每项功能设置默认值——请参阅解析器默认值。 - **守卫:**可使用
@UseGuards()在类级别应用守卫。
示例:
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...*/) {
/* ... */
}
}
也可以在解析器级别应用守卫:
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
}
提示词装饰器
在解析器类中装饰方法,将其公开为 MCP 提示词。接受与 @modelcontextprotocol/sdk 中的 server.prompt() 兼容的选项。name 应使用 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.' }] };
}
}
资源装饰器
在解析器类中装饰方法,将其公开为 MCP 资源。接受与 @modelcontextprotocol/sdk 中的 server.resource() 兼容的选项。name 应使用 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.' }] };
}
}
工具装饰器
在解析器类中装饰方法,将其公开为 MCP 工具。接受与 @modelcontextprotocol/sdk 中的 server.tool() 兼容的选项。name 应使用 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.` }] };
}
}
工具注释
annotations 字段允许提供有关工具行为的协议级提示,例如工具是否具有破坏性、是否为只读、是否幂等,或是否具有其他特殊属性。客户端、用户界面或协议本身可使用这些提示来显示警告、优化调用或强制执行策略。
常见注释键:
destructiveHint(布尔值):表示该工具会执行破坏性操作(例如删除数据)。readOnlyHint(布尔值):表示该工具不会修改任何数据。idempotentHint(布尔值):表示多次调用该工具都能安全地产生相同效果。openWorldHint(布尔值):表示该工具可能会在当前系统之外产生副作用。
示例:
@Tool({
name: 'reset_password',
paramsSchema: z.object({ userId: z.string() }),
annotations: { destructiveHint: true, idempotentHint: false }
})
resetPassword({ userId }: { userId: string }): CallToolResult {
// ...
}
ToolOptions 变体
| 变体 | 必填字段 |
|---|---|
| ToolBaseOptions | name |
| ToolWithDescriptionOptions | name, description |
| ToolWithParamOrAnnotationsOptions | name, paramsSchemaOrAnnotations |
| ToolWithParamOrAnnotationsAndDescriptionOptions | name, paramsSchemaOrAnnotations, description |
| ToolWithParamAndAnnotationsOptions | name, paramsSchema, annotations |
| ToolWithParamAndAnnotationsAndDescriptionOptions | name, paramsSchema, annotations, description |
paramsSchema和paramsSchemaOrAnnotations可以是用于输入验证的 Zod 架构。annotations是一个包含上述协议级提示的对象。
McpContext 参数
所有 MCP 能力方法(@Prompt、@Resource、@Tool)都会将
McpContext 对象作为最后一个参数。它扩展了 SDK 自带的
ServerContext,并包含调用到达时的 Express 请求。
在 2.0 中更名。 该类型原名为
RequestHandlerExtra,MCP SDK 已在 v2 中移除此类型。请参阅从1.x迁移到2.x。
来自 SDK 的属性:
mcpReq.id— 此请求的 JSON-RPC idmcpReq.method— 正在处理的方法,例如tools/callmcpReq._meta/mcpReq.envelope— 请求元数据和 2026-07-28 信封http.authInfo— 经过验证的访问令牌信息(如果运行了身份验证中间件)sessionId— 在 2026-07-28 流量中为undefined。 协议修订版 2026-07-28 废弃了会话。通过旧版回退机制服务的 2025 年客户端仍可能填充此字段。 切勿根据此字段执行授权分支判断。
由 @nestjs-mcp/server 添加:
request— 此调用对应的实时 Express 请求headers—request.headers的简写reportProgress(progress, total?, message?)— 向调用方发送进度更新;请参阅报告进度
用法示例:
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' }],
};
}
}
重要说明:
ctx始终是任何使用@Resource、@Prompt或@Tool装饰的方法的最后一个参数headers在 2.0 中改变了含义。 在 2.0 之前,它们是打开_连接_的请求 (initializePOST)的标头,并在连接的生命周期内保持不变。现在它们是本次调用自身的标头,因此可以正确获取在对话期间过期或被撤销的凭据。针对 1.x 编译的代码仍可正常编译,但开始接收的值会不同(且正确)。ctx无法进行 JSON 序列化:request是包含循环引用的 Express 对象,因此JSON.stringify(ctx)会抛出异常。请读取所需的字段。
报告进度
长时间运行的工具、提示词或资源可以告知调用方当前进度:
@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 会发送与客户端在请求 _meta 中提供的
progressToken 相关联的 notifications/progress:
- 没有令牌则不执行任何操作。 未请求进度的客户端(大多数客户端都不会请求) 不会收到任何内容,调用的响应与未使用此功能时完全相同。
- 永不拒绝。 无法再发送的更新(交换过程已经结束)会记录为
debug并丢弃。调用void ctx.reportProgress(…)是安全的。 - 可以脱离上下文使用。 它从不读取
this,因此可作为普通回调传给服务:await importer.run(rows, ctx.reportProgress)。 - 是否发送取决于
responseMode— 请参阅 进度与responseMode。 - 不进行验证或限流:请让
progress递增,并将更新频率控制在合理范围内。
⚠️
message会原样发送给调用方。请勿在进度消息中放入机密、凭据或 其他租户的数据。
动态能力
@Tool、@Prompt 和 @Resource 都接受可选的 enabled 选项,用于决定每次请求是否公布并允许调用相应能力。
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;
}
两种形式,共用一个选项键。不设置 enabled 时,默认行为与该选项出现之前完全相同:始终注册、始终启用,且不会增加请求开销。
静态切换
适用于只需关闭的能力。它不需要类、提供程序,也不需要 await。
@Tool({
name: 'legacy_tool',
description: 'Kept for compatibility, not offered to clients',
enabled: false,
})
legacyTool(): CallToolResult {
/* ... */
}
能力门控
适用于需要由服务端做出判断的情况。门控是一个类,会像守卫一样从 Nest 容器中解析,因此可以注入服务;其判断结果会通过 await 等待,因此可以执行真正的异步操作。
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 {
/* ... */
}
}
在拥有解析器的模块中将门控注册为提供程序:
@Module({
imports: [McpModule.forRoot({ name: 'admin', version: '1.0.0' })],
providers: [PermissionsService, AdminGate, AdminResolver],
})
export class AppModule {}
传入类,绝不要传入实例——在模块作用域中创建的实例无法访问容器,这正是门控必须是类的原因。如果检查只涉及廉价的内存操作,门控也可以同步返回结果(isEnabled(): boolean);boolean | Promise<boolean> 两种形式都适用。
请注意,McpRegistrationContext 不包含 sessionId,这是有意为之。它在 SSE 注册时存在,但在流式 HTTP 中不存在,因为传输层会在处理 initialize POST 时分配该 ID,而此时注册已经完成。若将其暴露出来,同一个门控在不同传输方式下就会表现不同。
客户端看到的内容
禁用的能力是先注册再禁用,绝不会跳过。因此,它不会出现在 tools/list / prompts/list / resources/list 中;调用它时会返回 Tool <name> disabled,而不是 Tool <name> not found——在 SDK 中两者是不同的,对于确实存在但对你关闭的能力,前者才是正确的响应。
五种情况下均故障关闭
无法作出判断的门控绝不会让能力继续暴露:
| 情况 | 结果 |
|---|---|
isEnabled 同步抛出异常 | 禁用,记录错误 |
isEnabled 返回被拒绝的 Promise | 禁用,记录错误 |
| 无法从容器中解析门控类 | 禁用,记录错误——绝不会通过 new 静默创建,因为此类门控的依赖项为 undefined,可能会意外返回真值 |
| 声明了门控,但不存在注册上下文 | 禁用,记录错误 |
disable() 本身抛出异常 | 能力保持启用,并在日志中说明——这是唯一残留的故障开放情况,因为没有其他方式可以禁用能力 |
门控失败绝不会影响其旁边的能力,也绝不会让请求变成 500。日志行只会注明能力和失败原因——绝不会记录请求、请求头或令牌。
使用前必须了解的规则
- 每个请求评估一次。 每个 HTTP 请求都会创建一个全新的 MCP 服务,这正是服务保持无状态的原因;
enabled会在服务填充过程中解析。更改门控所读取的任何内容,都会在客户端的下一次调用时生效;无需重新连接。(在 2.0 之前,判定结果在整个连接期间都不会改变。) - 不会发出
listChanged通知。 每个切换操作都会在服务连接到传输层之前应用,而 SDK 会在此处控制通知的分发。本库绝不会发送notifications/tools/list_changed或对应的提示词/资源通知。不要指望已连接的客户端会收到变更通知——会话中途不会发生任何变化。 - 保持门控开销低——它们处于请求处理路径上。 在为客户端提供服务之前,每个
tools/list和每个tools/call都会等待所有门控完成,因此,如果门控查询数据库,就意味着每个请求都会查询一次数据库。对于已经在使用此选项的人来说,这是 2.0 中最重要的行为变化。本库会尽可能限制开销:注册仍是同步的;所有门控会在一轮并发中完成(增加的延迟取决于最慢的单个门控,而非所有门控延迟之和);每种不同的门控类,每个请求只会从容器解析并_请求评估_一次,无论有多少功能共享它。至于门控本身,本库无法限制其开销——请在注入的服务中、提供方作用域内进行缓存。没有内置超时:如果门控始终无法完成,请求就会一直得不到响应。 - 没有注册上下文的门控会默认拒绝。 两种内置传输方式都会传递上下文。如果你自行通过单个参数调用
RegistryService.registerAll(server),那么任何声明了 门控 的功能都会被禁用,并记录原因。静态的true/false以及未设置enabled选项的功能不受影响。 authInfo可能是undefined。 只有当auth策略对调用方完成身份验证时(参见身份验证与授权),或者上游中间件设置了req.auth,才会填充此字段。请写成context.authInfo?.scopes——如果门控未加保护地直接访问该字段,就会抛出异常,随后默认拒绝,从而静默移除该功能。- 不支持请求作用域的门控提供方。 门控通过
moduleRef.get(..., { strict: false })解析,失败时回退到moduleRef.create。这两种方式都无法妥善处理请求作用域的提供方。请使用单例门控,从注册上下文中读取所需内容。
这不能替代守卫
enabled 是发现层面的纵深防御,不是授权机制。守卫会针对每次功能调用,使用该次调用自己的上下文运行。
原因在 2.0 中发生了变化,但结论依然成立。在 2.0 之前,理由很直接:注册上下文只根据 initialize POST 构建,不包含其他内容,因此后续调用不会重新检查。现在情况不同了——门控会看到每次调用各自的请求和 req.auth。不变的是二者在_类型_上的区别:门控会在列表中隐藏某项功能,而守卫会拒绝一次调用。隐藏不等于拒绝。客户端如果已经知道某个工具的名称,就可以不读取列表直接调用;只有守卫能阻止这种调用。
这与 MCP SDK 对其自身的逐请求工厂所采用的界限一致:HTTP 层验证 bearer token,而针对各工具的检查放在处理器中,因为_"处理器是执行该工具时的权威来源。"_。
使用 enabled 决定调用方应该看到哪些内容;使用守卫决定是否允许调用。现在二者看到的是同一个请求,因此可以在二者之间提取共用的判断逻辑——但隐藏功能并不等于强制执行:已经知道工具名称的客户端仍然可以调用它,只有守卫能阻止这种调用。
如果直接调用 RegistryService
RegistryService.registerAll 是 async 的,其第二个参数——注册上下文——必填:
await registry.registerAll(server, { request, era: 'modern' });
只有直接操作注册表本身的代码会受到影响;通常使用 McpModule 不受影响。完整的 2.0 变更列表,请参阅从 1.x 迁移到 2.x。
可运行示例位于
examples/dynamic/——EXAMPLE=dynamic pnpm start:example。
守卫
可将一个或多个守卫应用于 Resolver、单个方法或全局。守卫必须实现 NestJS 的 CanActivate 接口。
全局级守卫
此方法使用标准的 NestJS 全局守卫系统(APP_GUARD)。全局守卫会保护 所有 NestJS 路由,包括 MCP 端点(/mcp)。适用于广泛的身份验证,或适用于任何 MCP 专属逻辑运行前的检查。
// 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';
}
}
在主模块中全局注册守卫:
// 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 {}
通过 @UseGuards 附加的守卫接收的是 McpExecutionContext,而非 Nest 的
ExecutionContext。应实现 McpGuard(可选用 McpGuard<YourExtra> 指定
类型化身份),而不是 CanActivate:CanActivate 将其参数类型设为
ExecutionContext,因此在严格模式下,若参数类型为 McpExecutionContext,
则 implements CanActivate 无法编译。@UseGuards 接受这两种类型。全局
APP_GUARD 守卫是普通的 Nest 守卫,仍使用 CanActivate。
Resolver 级守卫
这是此库的自定义功能。Resolver 级守卫通过导出的 @UseGuards() 装饰器
(来自 @nestjs-mcp/server)应用于 Resolver 类。该 Resolver 中的所有 MCP
方法(@Prompt、@Resource、@Tool)都会受到这些守卫的保护。可用它为一组相关功能强制执行逻辑(例如角色检查)。
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
}
方法级守卫
这是此库的自定义功能。方法级守卫通过将 @UseGuards() 装饰器直接应用于 MCP 功能方法(@Prompt、@Resource、@Tool)来实现。只有被装饰的方法会受到这些守卫的保护。可用它对特定功能进行细粒度访问控制。
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 */
}
}
重要: Resolver 级和方法级守卫 仅在调用 MCP 功能时运行,不会在由全局守卫处理的初始连接建立阶段运行。它们使用自定义的 McpExecutionContext。
守卫示例
用于 Resolver 级或方法级保护的守卫:
// 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 执行上下文
使用此库的 @UseGuards() 实现 Resolver 级 或 方法级 守卫时,
canActivate 方法接收的是 McpExecutionContext。它提供对 MCP 专属信息的访问:
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';
}
}
McpExecutionContext 的要点:
getRequest()— 调用此功能时使用的 Express 请求。从 2.0 开始,它是调用本身的请求,因此过期或已撤销的凭据会被正确识别。在 1.x 中,它是连接握手请求,在整个连接生命周期内保持不变。getContext()— 完整的McpContext:mcpReq(id、method、_meta、信封)和http.authInfo。getArgs()— 传递给正在调用的 MCP 处理程序的参数。其结构取决于功能类型;可根据type判别属性缩小类型范围。getClass()/getHandler()— Resolver 类和方法。getAuthInfo?.()— 有效的AuthInfo(经auth.authorizers处理后),匿名请求则为undefined。此接口上将其设为可选,以确保现有测试替身仍能通过编译。getSessionId()已在 2.0 中移除。协议修订版 2026-07-28 废除了会话,因此没有可返回的 ID,也没有可查询的存储。- 这不是 Nest 的
ExecutionContext:没有switchToHttp()。请直接使用getRequest()。
依赖注入的守卫
守卫可以注入 NestJS 提供者。使用 @Injectable() 并将守卫注册为提供者:
@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 {}
容器无法解析或创建的守卫类会拒绝调用并记录错误(自 2.1 起)。请将带有依赖项的守卫注册为提供者。
身份验证与授权
身份验证由你自行实现。 此库不会决定调用方如何进行身份验证,也不会决定凭据通过何种方式传递。你的应用会注册身份验证策略——例如,在你选择的请求头、查询参数或 Cookie 中使用 API 密钥,或使用 Basic 身份验证、会话 Cookie、客户端证书、JWT、不透明 OAuth 令牌:任何可从 HTTP 请求中读取的信息都可以。此库提供相关基础设施:它会在 MCP 处理程序运行前执行这些策略,使用策略声明的质询返回 401/403/429 响应,将身份信息传递给授权器、gate、守卫和处理程序,并且——仅当你使用 OAuth 时——提供发现元数据。授权是你负责的独立层:请求授权器、每项功能的 scopes、gate 和守卫。
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
单个请求的执行顺序:策略 → 授权器 → gate(注册时的 enabled)→ SDK 的 scopes 质询 → 守卫(@UseGuards)→ 处理程序。 未配置 auth 时,端点的行为与之前完全相同。
配置 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 {}
每个策略和授权器都会在启动时从容器中解析。无法解析的类会阻止应用启动——此库绝不会回退到 new,因为这会绕过 DI。
使用 forRootAsync 时,请在模块旁边的 providers 中声明这些类;如果工厂返回 protectedResource,还需传入静态标志 protectedResourceMetadata: true(Nest 会在工厂运行前确定控制器):
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')],
},
},
}),
});
编写策略
策略会返回 SDK 的 AuthInfo(由此包重新导出)、null
(“我没有凭据,请尝试下一个”)或抛出异常:
| 结果 | HTTP 响应 |
|---|---|
返回 AuthInfo | 继续处理;跳过后续策略 |
所有策略都返回 null | 401 + WWW-Authenticate,包含所有已注册的质询(不含 error=) |
throw new McpUnauthorizedError(description) | 401 {"error":"unauthorized"} + 已注册的质询;停止处理 |
throw new OAuthError(OAuthErrorCode.InvalidToken) | 401 + Bearer error="invalid_token" 质询(OAuth 策略);停止处理 |
throw new McpHttpError(status, body, headers) | 原样写入(例如 429,并带有 Retry-After) |
| 其他任何情况 | 500,记录日志时不包含标头或令牌 |
AuthInfo 是 MCP SDK 的身份类型(处理程序通过 ctx.http.authInfo 接收该类型),与使用的方法无关:clientId 是已认证主体的 ID,token 是所提供的凭据。两者都是必填项。expiresAt 以纪元时间起算的秒为单位。如果返回的 AuthInfo 没有字符串类型的 token、字符串类型的 clientId 和由字符串组成的 scopes 数组,或者其 expiresAt 明显以毫秒为单位(大于 1e11),则这是一个错误,响应为 500;如果其 expiresAt 已经过期,则响应为 401 invalid_token。请将应用身份信息(用户 ID、密钥 ID、工作区)放入 AuthInfo.extra。CORS 预检请求(OPTIONS)永远不会进行身份验证。切勿在错误消息中放入凭据:意外错误只会按其类名和消息记录。
质询。 策略可以声明 readonly challenge,即告知客户端如何提供凭据的 RFC 9110 质询,例如 ApiKey header="x-api-key" 或 Basic realm="notes"。每个 401 都会列出所有已声明的质询;设置了 protectedResource 时,带有 resource_metadata 的 Bearer 质询会排在首位(MCP 客户端会查找它)。如果没有已声明的质询,也没有 protectedResource,则标头为裸 Bearer。如果 challenge 不是一个有效的 RFC 9110 质询,应用会在启动时停止;scopesSupported 或装饰器的 scopes 条目不是 OAuth scope-token(不得包含空格、引号、反斜杠或控制字符)时,也会如此。
从自选标头读取 API 密钥(非 OAuth;无需使用 OAuth 术语):
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 };
}
}
凭据的位置完全由策略决定:读取 req.query.api_key、req.cookies.session、req.socket.getPeerCertificate() 或任意标头。优先使用标头:查询字符串会进入访问日志和代理,而 MCP 授权规范禁止将其用于 OAuth 访问令牌。
通过 Bearer 传递 API 密钥的策略与上述策略相同,只是读取 Authorization。如果 Bearer 值不是你的密钥之一(例如,它的格式符合 JWT 的 xxx.yyy.zzz),则返回 null,以便列表中后续的 JWT 策略仍能处理它。
使用 jose 验证 JWT——检查受众是策略的职责(RFC 8707):不得接受为其他资源签发的令牌。
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 发现(protectedResource)
设置 protectedResource 后,库会在由 resource 推导出的路径提供 RFC 9728 受保护资源元数据文档
(https://notes.example.com/mcp → /.well-known/oauth-protected-resource/mcp),
并在库构建的质询中将其作为 resource_metadata 公布:
包括缺少凭据或凭据为 invalid_token 时的 401,以及
insufficient_scope 提权流程中的 403。携带自身质询的
McpAccessDeniedError、McpHttpError 或 McpUnauthorizedError 会按抛出时的内容写入。这样,
支持 OAuth 的 MCP 客户端(claude.ai、ChatGPT)就能找到你的授权
服务器。库不会提供授权服务器元数据——那属于 AS。
端到端试用 OAuth
examples/oauth 会运行一个受 OAuth 2.1 保护的 MCP 服务,
并在旁边启动一个行为类似真实提供商的模拟授权服务器,无需 Docker,也无需提供商账号。如何运行、演示用户和客户端、
失败场景,以及如何将模拟服务器换成 Auth0、Okta 或 Keycloak,详见
示例的 README。
请求授权器(第 2a 层)
McpAuthorizer 在每个请求通过身份验证后运行一次,接收当前的
AuthInfo 并返回用于后续处理的 AuthInfo。凡是会改变调用方_有效_作用域的情况,都应使用它——它必须在 SDK 看到
请求之前运行,因为 SDK 会在任何守卫之前评估 scopes:
@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 },
};
}
}
对于 403 { "error": "access_denied" }(重新授权也无法解决的情况),抛出 McpAccessDeniedError;
对于 403 提权质询,抛出 new OAuthError(OAuthErrorCode.InsufficientScope, …)。
返回的 AuthInfo 将用于门控;scopes、守卫(context.getAuthInfo())和处理器(ctx.http.authInfo)
都会使用它——两者都通过 getAuthInfo(ctx) 读取。
按功能配置作用域(第 2b 层)
@Tool、@Resource 和 @Prompt 接受 scopes:
@Tool({ name: 'add_note', paramsSchema: AddNote, scopes: ['notes:write'] })
SDK 会在分派之前,使用有效的 AuthInfo 检查这些作用域。
auth.scopeSatisfies(granted, required) 可覆盖默认检查(每个必需作用域都已获授权)。未持有这些作用域时的处理方式取决于
hideOutOfScope:
hideOutOfScope | 是否列出? | 调用时的行为 | 适用场景 |
|---|---|---|---|
false(默认) | 是 | 403 insufficient_scope,scope="<required scopes>" | 授权缺少用户可同意的作用域(OAuth 提权) |
true | 否 | “已禁用” | 限制源于重新授权无法解决的因素,例如工作区角色 |
未配置 auth 时,没有 AuthInfo 的请求不会收到质询
(与 SDK 的 requireScopes 行为一致)。配置了 auth 且设为
optional: true 时,匿名请求会发现带作用域的功能已禁用。
配置 auth 后,tools/list、prompts/list、resources/list 和
resources/templates/list 默认使用私有、TTL 为 0 的缓存提示,因为这些列表因调用方而异。
显式设置的 server.cacheHints 优先。
Resolver 默认值
@Resolver 也接受一个选项对象,其中的 scopes、public 和
hideOutOfScope 会成为该类所有能力的默认值。字符串形式 @Resolver('notes')
保持不变。
@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() {}
}
优先级为 能力 > resolver > 模块:能力的 scopes
会替换 resolver 的 scopes(而不是合并),能力的 public
会覆盖 resolver 的 public,而能力或 resolver 上的 hideOutOfScope
会覆盖 auth.hideOutOfScope。在 @Resolver({ public: true }) 类中声明自身
scopes 的能力受保护,而不是公开的。与方法装饰器一样,作用域令牌会在类定义时进行验证。
公开能力
在 @Tool、@Resource 或 @Prompt(或 @Resolver)上设置
public: true,即可让调用方在没有任何已识别策略的情况下访问该能力,
而 auth 对其他所有情况仍然是必需的:
@Tool({ name: 'search_docs', public: true })
searchDocs() {}
当配置了 auth,但未设置 optional: true,且所有策略都返回 null 时,通常会以 401 响应请求。如果模块声明了至少一个公开能力,则会改为检查已解析的 JSON-RPC 请求体;仅当其中的每条消息(单条消息或批处理)都符合以下条件时,才会匿名处理请求:
- 握手或存活状态消息——
initialize、server/discover、ping、任何notifications/*; - 列表请求——
tools/list、prompts/list、resources/list、resources/templates/list——此时只会列出公开能力; tools/call、prompts/get或resources/read,且目标为公开工具、提示词、 资源 URI,或匹配公开资源模板的 URI。
其他情况——其他方法、非公开目标、缺失或格式错误的请求体、GET——都会像以前一样返回相同的 401 和相同的质询。对于匿名请求,所有非公开能力都会被禁用(不会列出,直接调用时也会被“禁用”),因此只有公开能力可以访问。
- 凭据不会改变任何行为。 带有效凭据的请求看到和调用能力的方式与没有
public时完全相同;公开能力对所有人开放。 public和scopes不能同时使用。 在同一能力(或同一@Resolver)上同时声明二者会在类定义时抛出错误:“任何人”与“仅持有 X 授权的主体”相互矛盾,默默选用其中之一会意外暴露或锁定某项内容。- 没有公开能力,行为不变。 如果没有公开能力,身份验证流程与以前完全相同——不会检查请求体。
optional: true的含义不变。 所有匿名请求都会放行,不检查请求体;未限定作用域的能力仍对其可见,带作用域的能力则会被禁用。- 握手也是公开的。 只要有任何公开能力,
initialize和server/discover(包括服务器的instructions)就可以匿名读取,因此不要在其中放置机密信息。 - 支持两个协议时代。 检查会在 SDK 收到请求前对已解析的请求体执行,因此 2025 年代的客户端(例如
@modelcontextprotocol/sdkv1)也可以匿名使用:initialize→tools/list→ 公开的tools/call。
类型化身份(getAuthInfo)
getAuthInfo<TExtra>(ctx) 会返回有效的 AuthInfo——即策略生成并经授权器处理后的信息——可从处理程序的 McpContext 或守卫的 McpExecutionContext 中获取,其中 extra 的类型为 TExtra
(McpAuthInfo<TExtra>)。匿名请求的返回值为 undefined。
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 是对你的策略和授权器放入 extra 的内容所作的类型断言,而不是运行时检查。McpExecutionContext 接受相同的可选类型参数;不提供该参数时,getAuthInfo() 会像以前一样返回 SDK 的 AuthInfo。请为 TExtra 使用类型别名:接口需要索引签名,而索引签名会允许任意键。
每项工作该使用哪种工具
| 机制 | 运行时机 | 响应 | 适用场景 |
|---|---|---|---|
| strategy | 每次请求执行一次 | 401 | 识别调用方 |
| authorizer | 每次请求执行一次,认证后执行 | 403 / 范围缩小后的 AuthInfo | 租户/工作区成员资格、角色 ∩ 密钥范围 |
scopes | 每项功能执行一次,分发前 | 403 insufficient_scope / 隐藏 | 功能所需的 OAuth 范围 |
gate (enabled) | 每项功能执行一次,注册时 | 隐藏 +“已禁用” | 根据任意逻辑控制发现层级的可见性 |
guard (@UseGuards) | 每次调用执行一次,处理程序运行前 | 工具错误 | 检查每次调用的参数或正在访问的资源 |
选择在哪里进行授权
每一层回答不同的问题。该库绝不会授权数据: 它决定调用方是否可以调用某项操作,但绝不决定该操作可以访问哪些记录。
| 位置 | 回答的问题 | 形式 | 客户端看到的失败 |
|---|---|---|---|
scopes | 凭证/应用对每项操作获准执行什么 | 声明式,类似于控制器方法上的权限装饰器 | 403 insufficient_scope(需要提升权限),或隐藏 |
guards (@UseGuards) | 应用针对某项操作的规则(参数、时间、功能标志) | 代码,针对每次调用 | 工具错误 |
| authorizers | 对于此请求,调用方是谁:租户/工作区、角色 ∩ 凭证 scopes | 代码,每个请求执行一次;用于构造其他所有内容都会读取的 AuthInfo | 403 access_denied/范围缩窄 |
| application services | 数据级授权——“这条记录是否属于这个工作区?”——这是事实来源 | 你的领域代码,由处理器通过 getAuthInfo(ctx) 调用 | 处理器返回的任何结果 |
public | 哪些操作完全不需要身份 | 声明式,按能力或解析器配置 | 其他所有操作均返回 401 |
| resolver defaults | 一组操作共用的 scopes/public/hideOutOfScope 默认值 | 声明式,配置在 @Resolver({ … }) 上 | 与 scopes/public 相同 |
| typed identity | 处理器和 guards 如何读取调用方 | getAuthInfo<TExtra>(ctx) | — |
为什么范围检查失败会返回 403,而 guard 检查失败会返回工具错误。 scopes
在分派之前即可确定,因此 SDK 可以直接拒绝 HTTP 请求,返回 403 和 OAuth 客户端知道如何处理的
WWW-Authenticate 挑战(重新同意并授予更多范围)。Guard 在已分派的调用内部运行:此时
MCP 请求已被接受,HTTP 响应也已提交为 JSON-RPC 结果,因此唯一坦诚的处理方式就是为该工具调用
返回错误结果。任何客户端可以通过重新授权来解决的问题,都应放在 scopes(或
authorizer)中;guards 则用于处理重新同意无法改变的规则。
需要了解的事项
- 全局
APP_GUARD也会在/mcp上运行。 全局注册的 REST 守卫也会看到 MCP 流量;请改为按控制器应用 REST 守卫。这些守卫会在 MCP 控制器验证请求之前运行,因此此时req.auth仍为undefined:请使用auth.authorizers、在解析器上使用@UseGuards或使用能力门来授权 MCP 调用者;这些方式都能看到AuthInfo。 - MCP 端点和元数据路由不进行版本控制。
/mcp和/.well-known/oauth-protected-resource…以VERSION_NEUTRAL提供服务,因此即使应用启用了 URI 版本控制,它们仍会保留在这些路径上。 - 全局前缀。 如果调用
app.setGlobalPrefix(),请排除 MCP 路由:app.setGlobalPrefix('api', { exclude: ['mcp', '.well-known/{*path}'] })。 - 无法解析的守卫现在会拒绝请求(自 2.1 起)。过去,容器无法解析的
@UseGuards类会回退到new Guard();现在则会拒绝请求并记录错误日志,与能力门的行为一致。
请参阅 examples/auth,了解一个可运行的服务,其中包含 API 密钥策略、JWT 策略、租户授权器和作用域工具。
无状态
自 2.0 起,此库不会在请求之间保存任何状态。没有会话存储、没有会话 ID,也不需要粘性路由。
SDK 的 createMcpHandler 会根据此库提供的工厂,为每个 HTTP 请求创建一个全新的 McpServer。该工厂会针对正在处理的请求运行 RegistryService.registerAll,因此每个能力处理器和每个守卫都会闭包捕获该请求。响应结束后不会保留任何内容。
这样做的好处
- 可在普通轮询负载均衡器后运行任意数量的实例。无需粘性会话、共享会话存储,也无需在网关进行会话固定。
- 实例可在对话过程中重启或自动扩缩容,而不会导致客户端断开。
- 可部署到无法保持连接开启的无服务器和边缘运行时。
这是对 #121 的解决方案,并通过 test/stateless-load-balancing.e2e-spec.ts 进行了端到端验证:两个独立构建的 Nest 应用位于严格轮询代理后方,由一个 MCP 客户端驱动,并断言 Mcp-Session-Id 从未通过网络传输。
获取请求数据
过去需要通过 SessionManager 查找来获取的所有内容,现在都会直接交给你,而且描述的是_当前_调用,而不是连接握手:
// 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;
}
传输选项
该库只公开一个 MCP 端点:ALL /mcp。
在 2.0 中变更。 1.x 提供了
POST/GET/DELETE /mcp,以及GET /sse和POST /messages,并支持按传输方式设置enabled开关。HTTP+SSE 传输已移除:它在结构上依赖粘性会话——必须在同一个实例上配对一个长期运行的GET /sse和POST /messages——而这正是无状态设计所要解决的问题。GET和DELETE对/mcp的请求属于 2025 年时期的会话操作,现在会返回405。
向后兼容。 2025 年时期的客户端仍可通过 SDK 的无状态旧版回退机制,在同一端点上使用。所有当前已发布的 MCP 客户端 SDK——包括 @modelcontextprotocol/sdk@1 和
@modelcontextprotocol/client@2——都支持该时期的协议,且二者均已纳入端到端测试套件。
选项会直接传递给 SDK 的 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,
},
});
| 选项 | 用途 |
|---|---|
legacy | 如何处理 2025 年时期的流量。默认值为 'stateless'。 |
responseMode | 现代交互的响应格式。默认值为 'auto'。 |
keepAliveMs | SSE 注释帧的保活间隔。默认值为 15000。 |
maxSubscriptions | subscriptions/listen 流的最大数量。默认值为 1024。 |
bus | subscriptions/listen 的变更事件总线。可替换为 Redis,以便在多个实例间广播通知。 |
进度与 responseMode
ctx.reportProgress 是否会传达到客户端,取决于请求所属的协议时代以及 responseMode:
| 请求时代 | 'auto'(默认) | 'sse' | 'json' |
|---|---|---|---|
| Modern(2026-07-28) | 已传达 | 已传达 | 已丢弃 — 发出一条警告,见下文 |
| Legacy(2025,SDK 回退) | 通过 SSE 传达 | 通过 SSE 传达 | 通过 SSE 传达 |
- Modern +
'json'。 SDK 会在此模式下丢弃调用过程中的通知。处理程序首次调用reportProgress时,会在@mcp:progress下记录一条warn日志;SDK 还会在启动时针对任何'json'端点打印一次自己的console.warn。调用本身仍会成功。 - Legacy 时代。 SDK 的 Legacy 回退始终通过 SSE 响应调用,且不应用
responseMode,因此每种模式下都会传达进度。 'auto'下的 SSE 升级。 如果没有进度,Modern 调用会返回一个application/json正文。首个进度帧发出后,响应变为200 text/event-stream— 先是进度帧,然后是结果。从此之后发生的错误会作为 JSON-RPC 错误随流发送,而不是作为 HTTP 状态码发送:依赖状态码进行判断的客户端会看到200。
服务器级协议选项位于 server 下,并会原样传递给 SDK:
server.* | 用途 |
|---|---|
requestState.verify | 验证 MRTR 重试请求中回传的状态 — 见在不同实例上恢复 |
cacheHints | 服务器可缓存方法(tools/list、server/discover 等)的 ttlMs / cacheScope。每项资源的提示应放在 @Resource 上。 |
inputRequired | MRTR 的 maxRounds、roundTimeoutMs |
jsonSchemaValidator | 替换 JSON Schema 验证引擎 |
⚠️
legacy: 'reject'会拒绝当前发布的所有客户端 SDK,因为它们都还不支持 2026-07-28 时代。只有在你能控制所有调用方,且它们会发送原始请求时,才选择此项。
MCP 2026-07-28 功能
多轮往返请求(MRTR)
2026-07-28 规范以重试握手取代了由服务器发起的 elicitation/create 和 sampling/createMessage — 它们需要保持双向流打开,因此需要会话。需要输入的处理程序会返回 inputRequired(...);客户端会附上答案,重新发送相同的调用,然后处理程序完成处理。
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}` }] };
}
}
在不同实例上恢复
上面的处理程序会根据参数重新推导所有内容,因此任何实例都可以处理重试请求。当往返过程必须携带状态时,请将其封装到 requestState 中 — 状态会经由客户端往返传递,因此它是由攻击者控制的输入,而 SDK 默认不会提供完整性保护。
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 }>();
如果没有 verify 钩子,回传的状态会在未经验证的情况下被接受。启用该钩子后,遭到篡改的值会被拒绝 — 这两种情况都涵盖在 test/mcp-features.e2e-spec.ts 中。
客户端必须在每个请求的能力中声明
elicitation: { form: {} },否则服务器会拒绝发出请求(-32021)。
ctx.mcpReq.elicitInput 和 ctx.mcpReq.requestSampling 仍然存在,但已弃用,并且在 2026-07-28 请求中会抛出异常。MRTR 是唯一可用的路径。
结构化输出和显示元数据
@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 和 _meta 也可用于 @Prompt。
资源缓存提示
@Resource({
name: 'catalog',
uri: 'catalog://items',
cacheHint: { ttlMs: 60_000, cacheScope: 'public' },
})
ttlMs 和 cacheScope 随 resources/read 结果一起返回,使客户端可以缓存,而不是重新获取。此设计仅适用于资源:tools/list 和
prompts/list 为整个服务返回一个结果,因此没有地方可放置针对单项能力的提示——这些情况请使用
server.cacheHints。
发出 list_changed
注入 McpHttpService 并向其通知器发布。这是该库首次能够发出变更通知。
@Injectable()
export class PermissionsService {
constructor(private readonly mcp: McpHttpService) {}
onRoleChanged(): void {
this.mcp.notify.toolsChanged();
}
}
通知会发送给持有 subscriptions/listen 流的客户端。在多实例部署中,请提供由 Redis pub/sub 支持的
transport.bus,以便某个实例上的变更能够广播给其他实例上的订阅者。
从 1.x 迁移到 2.x
2.0 采用 MCP 规范 2026-07-28,该规范废弃了会话,并改用 MCP SDK 的 v2 包系列。现有 MCP 客户端仍可正常使用——它们通过 SDK 的无状态旧版回退机制提供服务——但服务端 API 已有变化。
安装
# 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
破坏性变更
| 1.x | 2.x | 原因 |
|---|---|---|
RequestHandlerExtra | McpContext | SDK 移除了此类型。ctx.request 现在是实时的 Express 请求。 |
extra.sessionId | (已移除) | 2026-07-28 废弃了会话。ctx.sessionId 仍存在,但在现代流量中为 undefined。 |
extra.headers | ctx.headers | 含义已变更——详见下文。 |
extra.body(未记录) | ctx.request.body | 现在已声明,且来自发起请求。 |
SessionManager | (已移除) | 不再有会话存储。请使用 ctx.request 或 context.getRequest()。 |
McpExecutionContext.getSessionId() | (已移除) | 没有可返回的内容。请使用 getRequest() 或 getContext()。 |
paramsSchema: { a: z.string() } | paramsSchema: z.object({ a: z.string() }) | v2 使用 Standard Schema;不再支持原始 Zod 结构。 |
argsSchema: { … } | argsSchema: z.object({ … }) | 同上。 |
transports: { sse, streamable } | transport: { legacy, responseMode, … } | 使用单一端点;选项会传递给 createMcpHandler。 |
session: { sessionTimeoutMs, … } | (已移除) | 不再有需要超时的会话。 |
GET /sse + POST /messages | (已移除) | HTTP+SSE 在结构上依赖粘性会话。 |
GET / DELETE /mcp | 405 | 这些是会话操作。 |
McpModule.forFeature() | 仍存在 | 未变(仍为空操作)。 |
沉默的那个——请阅读
extra.headers 在 1.x 中可以编译,在 2.x 中也可以编译,但返回的内容有所不同。
在 1.x 中,它是打开连接的请求所带的标头——initialize POST 请求,或 GET /sse 握手请求的标头。这些标头从会话存储中恢复,并在连接的整个生命周期内保持不变。在 2.x 中,它是当前所处理调用的标头。
这是一个错误修复,也是此次发布的重点:现在可以看到会话进行期间过期或被撤销的 Authorization 标头。但如果你依赖连接级别的值,它们将不再跨调用持久保留。请从工具中创建一个显式句柄,并让模型将其作为参数传回。
处理程序签名
- 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 }] };
}
}
防护措施
@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;
}
}
客户端兼容性
无需更改客户端。legacy: 'stateless' 是默认设置,可在同一个 /mcp 端点为 2025 年的客户端提供服务。只有在你控制所有调用方的情况下,才设置 transport.legacy = 'reject'——目前还没有已发布的客户端 SDK 支持 2026-07-28 年代的协议。
Inspector Playground
使用 Inspector Playground 在浏览器界面中交互式测试和调试 MCP 服务端点。此工具由 @modelcontextprotocol/inspector 提供支持,可用于:
- 探索可用的资源、工具和提示词
- 调用端点并实时查看响应
- 根据 MCP 规范验证服务端实现
要启动 Inspector Playground(请确保 NestJS MCP 服务正在运行):
npx @modelcontextprotocol/inspector
它通常会默认连接到 http://localhost:3000,你也可以指定其他目标 URL。
示例
examples/ 目录包含可直接使用的场景,演示如何注册和公开 MCP 功能。
每个示例都是独立的,并遵循最佳实践。如需了解高级用法,请参阅每个示例中的代码和文档。
更新日志
请参阅 CHANGELOG.md 了解发行说明。
许可证
MIT — 详情请参阅 LICENSE。
贡献
欢迎贡献!请参阅 CONTRIBUTING.md,了解指南、问题报告和拉取请求规则。
贡献前,请阅读我们的 Code of Conduct,了解我们社区对行为的期望。
评论
一个开放协议,规定了 AI 应用如何以统一方式连接外部工具与数据源。客户端负责与模型对话,MCP 服务负责提供工具、资源和提示模板,两者通过标准消息通信,同一个服务可以被不同客户端复用。