@lark-apaas/nestjs-mcp
v0.1.1
Published
NestJS MCP Server module for Miaoda fullstack apps (Streamable HTTP + MCP Apps)
Downloads
2,992
Readme
@lark-apaas/nestjs-mcp
为妙搭全栈应用(NestJS)提供 MCP Server 能力:用装饰器把业务方法开放为 MCP 工具,SDK 负责协议、装配、入参校验、身份读取与清单导出,并将应用 Skills 暴露为标准 MCP Prompts,将交互界面暴露为 MCP Apps 资源。
- 端点:
POST /__innerapi__/mcp(Streamable HTTP 无状态模式,不含 SSE;协议版本由@modelcontextprotocol/sdk协商,当前 2025-11-25)。应用设置CLIENT_BASE_PATH时完整路径为${CLIENT_BASE_PATH}/__innerapi__/mcp - 底层:
@modelcontextprotocol/sdk+@modelcontextprotocol/ext-apps(MCP Apps,ui://单文件 HTML 资源) - 身份:网关注入
x-larkgw-suda-webuser,UserContextMiddleware解析到req.userContext,工具通过ctx.user读取 - 装配:已被
PlatformModule.forRoot()自动引入,业务工程 import@lark-apaas/fullstack-nestjs-core即获得
能力与目录
| 应用能力 | 定义方式 | 客户端发现与使用 |
|---|---|---|
| 工具(执行操作、查询数据) | @McpTools() + @McpTool() | tools/list → tools/call |
| Skill(业务流程说明) | server/mcp/skills/<name>/SKILL.md | prompts/list → prompts/get |
| Apps UI(交互界面,可选) | @McpUiResource() + 独立 HTML 入口 | 工具的 _meta.ui.resourceUri → resources/read |
server/mcp/
├── tools/order.tools.ts
├── skills/
│ ├── order-status/SKILL.md
│ └── order-review/SKILL.md
└── ui/ # 可选,独立浏览器程序
└── order-detail/index.html工具与 UI 资源所在的类必须注册为已加载业务模块的单例 provider;Skill 文件自动发现,无需装饰器或 provider。一个 Skill 可以描述多个工具的使用流程,多个 Skill 也可以复用同一工具,不要求一一对应。
快速开始
1. 定义工具类
// server/mcp/tools/order.tools.ts
import { z } from 'zod';
import {
McpTools, McpTool, McpToolError,
type Infer, type McpContext, type McpToolResult,
} from '@lark-apaas/fullstack-nestjs-core';
import { OrderService } from '@server/modules/order/order.service';
export const GetOrderInput = { orderId: z.string().describe('订单 ID') };
export const GetOrderOutput = { orderId: z.string(), amount: z.number(), status: z.string() };
@McpTools({ prefix: 'order_' })
export class OrderMcpTools {
constructor(private readonly orders: OrderService) {}
@McpTool({
title: '查询订单',
description: '按订单 ID 查询订单金额与状态。用户询问某个订单的情况时调用。',
inputSchema: GetOrderInput,
outputSchema: GetOrderOutput,
annotations: { readOnlyHint: true },
})
async get(input: Infer<typeof GetOrderInput>, ctx: McpContext): Promise<McpToolResult<typeof GetOrderOutput>> {
const order = await this.orders.findVisibleTo(input.orderId, ctx.user.userId);
if (!order) throw new McpToolError('订单不存在', { code: 'ORDER_NOT_FOUND' });
return { structuredContent: order };
}
}2. 注册为所属业务模块的 provider
import { Module } from '@nestjs/common';
import { OrderService } from './order.service';
import { OrderMcpTools } from '@server/mcp/tools/order.tools';
@Module({
providers: [OrderService, OrderMcpTools],
})
export class OrderModule {}应用启动时 McpModule 自动发现已注册的工具类。已有业务模块无需额外在 app.module.ts 注册工具;新建业务模块仍需接入应用模块。启动日志会打印已注册的工具名。
3. 添加业务 Skill
创建 server/mcp/skills/order-status/SKILL.md:
---
name: order-status
description: 查询订单当前状态并向用户说明处理进度
---
先确认用户要查询的订单 ID,再调用 order_get。
根据工具返回的 status 和 amount 说明结果,不推测未返回的信息。
订单不存在或无权访问时告知用户,不重复尝试其他人的订单。每个业务流程使用独立目录和固定文件名 SKILL.md。例如再添加 order-review/SKILL.md,就会发现第二份 Skill。它们提供给应用使用者的 Agent,与指导生成代码的 mcp-guide 不同。只有 Skill、没有工具或 UI 的应用也可连接 MCP。
4. 发现与调用
在已连接的标准 MCP 客户端中:
const { prompts } = await client.listPrompts();
// [{ name: 'order-status', description: '查询订单当前状态并向用户说明处理进度' }, ...]
const prompt = await client.getPrompt({ name: 'order-status' });
// prompt.messages[0].content.text 为去掉 YAML 前言的 Markdown 正文
const result = await client.callTool({
name: 'order_get',
arguments: { orderId: 'order_001' },
});prompts/get 只返回使用说明,不会自动执行工具;后续由客户端或 Agent 按说明调用 tools/call。当前 Prompt 不声明参数,返回 description 和 messages: [{ role: "user", content: { type: "text", text: "Markdown 正文" } }]。Skill 不注册为 Resource,不使用 skill:// URI,也没有自定义 skills/list 方法。
以上方法均通过同一个 HTTP 端点发送 JSON-RPC 请求,例如 {"jsonrpc":"2.0","id":2,"method":"prompts/get","params":{"name":"order-status"}},没有 /mcp/prompts/get 等子路由。先完成 MCP 握手;业务调用的身份要求见下表。
约定
| 项目 | 约定 |
|---|---|
| 工具类位置 | server/mcp/tools/*.tools.ts |
| 工具名 | prefix + 方法名(或 name 覆盖),需匹配 ^[A-Za-z0-9_.-]{1,128}$,全局唯一 |
| 方法签名 | (input: Infer<typeof InputSchema>, ctx: McpContext) => McpToolResult<typeof OutputSchema> |
| schema | zod(与 @modelcontextprotocol/sdk 一致),原始形状 { a: z.string() } 或 z.object({...}) 均可 |
| 返回值 | 声明 outputSchema 时返回 { structuredContent }(SDK 自动补 content 文本);否则返回 { content: [...] } |
| 业务错误 | 抛 McpToolError(message, { code?, data? }),原样返回给 Agent(data 会随结果返回,勿放敏感字段);其他异常记日志并返回通用失败提示 |
| 身份 | 默认 requireUser: true:请求缺少用户身份时返回 MCP_USER_REQUIRED 错误,不执行业务代码 |
| 权限 | 复用业务 Service 与数据库行级权限,工具层只负责把 ctx.user 传下去 |
Skill 文件与更新规则
| 项目 | 要求 |
|---|---|
| name | 建议与目录名相同(开发约定,运行时不强制);1–64 位小写字母、数字和连字符,不允许首尾连字符或连续 --;全局唯一 |
| description | 非空字符串,最多 1024 个 UTF-16 代码单元;支持 YAML 引号和多行字符串 |
| 文件 | 普通 UTF-8 文件;拒绝符号链接、路径越界、非法 UTF-8 和非普通文件 |
| 数量与大小 | 最多 100 份,单文件最多 1 MiB,含前言的正文总量最多 8 MiB |
| 错误处理 | 新目录必须有合法完整的 YAML 前言;重复键、别名引用等报错;不会静默返回部分列表 |
实际 Prompt 名称取 frontmatter 的 name,源码定位保留真实文件路径;仅修改 name 无需同步重命名目录或重启服务,下次请求即生效。
只发现 skills/ 下一级目录内的 SKILL.md,不加载附件;没有该文件的普通目录会被忽略。对外使用 skills 数组,每份 Skill 对应一个标准 Prompt。
每次 MCP 请求或平台目录查询都重新读取 Skill 文件,单次请求使用同一份快照;增改删在下一请求生效。工具定义来自本次应用启动的注册表,修改工具代码需要开发服务重载;生产修改需重新构建并发布。当前无主动变更通知,能力声明为 listChanged: false,客户端需主动重新获取列表或正文。
MCP Apps(可选)
当工具结果需要界面渲染(如订单详情卡片)时,声明一个 ui:// 资源并在工具上关联:
import { McpTools, McpTool, McpUiResource, readMcpUiTemplate } from '@lark-apaas/fullstack-nestjs-core';
// 在已注册的工具类中增加资源方法,并关联对应工具(以下省略业务实现)。
@McpTools({ prefix: 'order_' })
export class OrderMcpTools {
@McpTool({ description: '…', inputSchema, outputSchema, ui: { resourceUri: 'ui://order/detail' } })
async get(/* … */) { /* … */ }
@McpUiResource({ uri: 'ui://order/detail', title: '订单详情' })
detailView() {
return readMcpUiTemplate('order-detail'); // 读取 dist/mcp-ui/order-detail.html
}
}界面源码放在 server/mcp/ui/order-detail/index.html(独立浏览器代码,使用 server/mcp/ui/tsconfig.json 检查;可用 React + @modelcontextprotocol/ext-apps 的 App / hooks),@lark-apaas/fullstack-vite-preset 和 @lark-apaas/coding-preset-vite-react 均会把每个入口打包为单文件 HTML:开发态输出 dist/mcp-ui/<entry>.html 并监听变更自动重建,生产构建直接输出 dist/dist/mcp-ui/<entry>.html。界面子构建只带 React 插件与 @shared 别名,不支持 styled-jsx 与 @server/*。目前仅 Vite 预设提供该构建,Rspack 预设不支持 MCP Apps。
模块配置
PlatformModule.forRoot({
mcp: {
serverName: 'my-app', // 默认 SUDA_APP_ID,未设置时为 miaoda-app
serverVersion: '1.0.0', // 静态声明,不随应用修改或 SDK 升级自动变化
requireUser: true, // 默认:执行工具、获取 Prompt、读取资源需身份
instructions: '…', // initialize 响应中的说明
},
});
// 关闭端点:PlatformModule.forRoot({ mcp: false })端点与身份
这里列的是应用内部路径,设置 CLIENT_BASE_PATH 时加上该前缀。外部客户端使用平台提供的 MCP 连接地址与凭证,由网关转发到内部端点;不要把页面地址直接拼接内部路径作为公网连接地址。本地开发与沙箱开发使用相同协议和目录约定,不新增独立端口。
| 请求 | 响应 / 默认身份要求 |
|---|---|
| POST /__innerapi__/mcp:initialize、tools/list、resources/list、prompts/list | HTTP 200,标准 JSON-RPC 响应;SDK 不要求用户身份,列表需按已声明能力调用 |
| 同端点 tools/call | 要求 ctx.user.userId;缺失返回 isError: true,错误码 MCP_USER_REQUIRED 在结果元数据中 |
| 同端点 resources/read、prompts/get | 要求用户身份;缺失返回 JSON-RPC error |
| 同端点 notifications/initialized | HTTP 202,空响应体 |
| GET / DELETE MCP 端点 | HTTP 405,Allow: POST;不提供 SSE 流 |
| POST 时工具、UI 资源和 Skill 均为空 | HTTP 404 |
| POST 的 Accept 未同时包含 application/json 和 text/event-stream | HTTP 406;标准客户端会设置这两个值,即使服务端使用 JSON 响应 |
| GET /__innerapi__/mcp/manifest | 平台目录接口,见下文;不需要 MCP 握手 |
tools/list、resources/list 仅在注册对应能力时可用,否则返回 JSON-RPC -32601 Method not found。客户端应根据 initialize 的 capabilities 调用;prompts/list 在服务可用时支持返回空数组。
平台负责凭证鉴权和可信身份注入;SDK 检查用户身份是否存在,并交给业务 Service 执行权限校验。工具类使用单例 provider,不承诺工具方法上的 Nest Guard / Pipe / Interceptor 自动执行,也不能依赖 HTTP Controller 上的权限装饰器。requireUser: false 可关闭 SDK 的身份存在性检查,不会关闭网关鉴权。
initialize.result.protocolVersion 是 MCP 协商版本,serverInfo.version 是模块配置的服务实现版本;下文的 version: 3 是平台清单结构版本。三者用途不同,均不能直接当作应用源码或 UI 内容更新标识。
导出
McpModule、McpTools、McpTool、McpUiResource、McpToolError、readMcpUiTemplate、
类型 McpContext / McpUser / McpToolOptions / McpToolResult / Infer / McpModuleOptions / McpCatalog / McpSkill / McpNamedSkill / McpSources / McpSourceLocation,
以及端点、清单和 Skill 常量。内部注册表与构建函数不作为包入口 API 导出。
应用修订与旧 UI 保护
应用修订通过 MCP 标准预留的 _meta 扩展传递,不改变 serverInfo.version、protocolVersion 或平台目录 version。键为 com.feishu.miaoda/appRevision,值是不透明字符串,只比较相等,不比较大小。此机制是妙搭扩展,普通 MCP 客户端不传该键仍可正常调用。
| 位置 | 含义 |
|---|---|
| 成功响应 result._meta["com.feishu.miaoda/appRevision"] | 当前请求使用的修订;包括 initialize、列表、工具、资源和 Prompt 响应 |
| tools/call、resources/read、prompts/get 的 params._meta 同名键 | 调用方期望使用的修订,可选;提供时必须为 1–256 字符的字符串 |
宿主通过 tools/list(纯 Skill 应用可用 prompts/list)刷新修订,将应用/环境/用户、修订和资源 URI 纳入缓存键。旧 UI 发起请求时由 Host 附加原绑定值,不允许用查询到的新值替换它。示例:
const key = 'com.feishu.miaoda/appRevision';
const revision = (await client.listTools())._meta?.[key];
if (typeof revision === 'string') {
const result = await client.callTool({
name: 'order_get',
arguments: { orderId: 'o_1' },
_meta: { [key]: revision },
});
}不一致返回 HTTP 200 的 JSON-RPC error,不执行本次业务 handler:
{
"jsonrpc": "2.0",
"id": 2,
"error": {
"code": -32000,
"message": "应用已更新,请重新调试;本次操作未执行",
"data": {
"code": "MCP_APP_REVISION_MISMATCH",
"expectedRevision": "old",
"actualRevision": "new"
}
}
}Host 按 error.data.code 处理,保留旧结果、禁用旧 UI 并提示重新调试;不得自动重放可能产生副作用的调用。非法修订参数返回 -32602。此检查不是鉴权,原有身份校验仍然生效。
- 开发态:发现已注册的工具、资源或 Skill 后,在 Nest 启动完成前,对实际 Node 入口所在的后端编译产物与依赖配置生成固定指纹,再结合已构建 UI 内容与 Skill 内容计算 SHA-256。相同产物和依赖重启不变;后端更新在新进程生效后变化,不因尚未生效的源码编辑提前变化。本地与沙箱规则一致。默认识别
dist/server+dist/shared或扁平dist布局,排除客户端、MCP UI、source map 和临时文件;UI/Skill 仍按运行快照独立更新。此规则适用于编译完成后启动、依赖按锁文件安装的标准工程;不跟踪手工替换node_modules或运行期间自行热替换后端模块。非标准入口(如直接运行 TypeScript)请显式提供与运行代码对应的appBuildId;无法识别时不返回修订,不伪造固定版本。 - 发布态:SDK 在初始化时读取 veFaaS 注入的
_FAAS_FUNC_ID与正整数_FAAS_REVISION_NUMBER,结合 UI/Skill 内容形成修订。同一发布 revision 跨实例、重启保持一致,新后端发布(含依赖变更)更新;仅重新构建但未部署不会影响运行版本。无需模板写入构建 ID 文件。其他部署平台通过PlatformModule.forRoot({ mcp: { appBuildId: 'immutable-release-id' } })显式提供不可变版本。 - 版本缺失:缺少有效平台版本且未配置
appBuildId时不返回修订,普通 MCP 仍可用;绑定调用返回-32000、data.code=MCP_APP_REVISION_UNAVAILABLE,不允许伪造固定版本。 - 按需初始化:无 MCP 能力时不扫描后端编译产物。纯 Skill 应用也在启动时固定后端指纹。开发进程启动后才新增首份 Skill,标准 Prompt 可直接使用,但须重启开发服务才能获得自动修订;未固定后端快照前不返回绑定版本。
- 一致性边界:MCP 请求中的
readMcpUiTemplate()复用该请求的 HTML 快照,避免修订与读取内容错配。仅涵盖固定 UI 产物及 Skill;动态业务数据不是代码修订。已经开始的请求可完成,保留开始时的修订;不撤销已发生的副作用,也不承诺后端/UI/Skill 在开发态整体原子切换或滚动发布期间全局最新版本校验。 - 更新发现:当前不提供 SSE 主动通知;Host 在重新调试、恢复对话、交互前刷新列表,或结合平台变更事件刷新。URI 不变不代表内容没变,不能仅按 URI 缓存。
依据:MCP _meta 扩展规则。
平台定义查询
GET ${CLIENT_BASE_PATH}/__innerapi__/mcp/manifest 返回完整平台目录:
interface Catalog {
version: number; // 当前为 3,平台响应结构版本
generatedAt: string; // 本次响应生成时间,UTC
endpoint: string; // /__innerapi__/mcp,相对应用根
tools: Tool[];
resources: Resource[];
prompts: Prompt[];
skills: Array<{ name: string; description: string; path: string; content: string }>;
sources: {
tools: Record<string, { path: string; line?: number }>;
resources: Record<string, { path: string; line?: number }>;
prompts: Record<string, { path: string; line?: number }>;
};
}tools、resources、prompts 完整复用标准 MCP 列表定义;UI 关联仍为工具 _meta.ui.resourceUri。平台目录 v3 的 skills 保存 { name, description, path, content },content 是含 frontmatter 的完整源文件,供面板展示;标准 Prompt 获取正文时去掉前言。sources.tools 按工具名、sources.resources 按 UI URI、sources.prompts 按 Prompt 名提供源码定位,Skill 也可直接使用 skills[].path。未配置时数组为空,读取失败不能伪装为空。generatedAt 是响应时间,不代表源码版本;平台负责工程版本匹配。
面板无需 MCP 握手;这是平台自定义 HTTP 接口,其外层结构不是 MCP 标准方法的返回值。接口不读写 .spark/mcp/manifest.json,每次读取 Skill 正文,返回 Cache-Control: no-store。空应用仍返回完整结构和空值;模块关闭为404,未就绪503,定义生成或文件读取失败500(RFC9457)。入口访问权限由平台负责。
开发检查
应用启动时自动检查工具和资源定义,包括名称格式、重名及 UI 资源引用;定义错误会阻止启动。修改代码后,确认应用成功重启,再通过平台目录接口和标准 MCP 客户端验证当前能力。Skill 文件在读取时校验;类型检查、lint 与生产构建仍使用工程现有命令。
SDK 不提供校验 CLI,也不生成离线 manifest 文件;能力目录以运行中的接口为准。
构建与交付
生产运行时从 cwd/server/mcp 读取 Skills、从 cwd/dist/mcp-ui 读取 UI。NRF 统一以 dist 为发布根,以下路径均相对构建前的工程根目录:
| 交付根 / 运行 cwd | 启动命令 | Skill 目录 | UI 目录 |
|---|---|---|---|
| dist | node server/main.js | dist/server/mcp/skills | dist/dist/mcp-ui |
只复制 skills/<name>/SKILL.md,不复制附件。清理删除项时保留相邻工具编译产物。无需 UI 也交付 Skills;自定义构建须满足相同的运行目录约定。
| 构建预设 | Skill 交付 | MCP UI 单文件构建与交付 |
|---|---|---|
| @lark-apaas/fullstack-vite-preset | 支持 | 支持 |
| @lark-apaas/coding-preset-vite-react | 支持 | 支持 |
| @lark-apaas/fullstack-rspack-preset | 支持 | 不支持 |
Vite 开发态监听 UI / shared 变更并重建;生产发布前需完成构建,确保 Skill 文件和 UI 产物进入运行目录。
MCP UI 编译与依赖边界
Skills、Tools、UI 统一放在 server/mcp/,但 ui/ 是 iframe 内的浏览器程序。开通 UI 时,保留原配置并向 tsconfig.node.json 的 exclude 追加 server/mcp/ui;服务端不能 import UI 源码,只能通过 readMcpUiTemplate 读取构建产物。
主工程保留原 client/server 检查。Agent 从工程根执行 npx --no-install tsc --noEmit --project server/mcp/ui/tsconfig.json,再执行 npx --no-install eslint --config server/mcp/ui/eslint.config.cjs "server/mcp/ui/**/*.{ts,tsx,js,jsx}" --max-warnings 0;必须检查退出码并修复错误。无需辅助脚本或新增 npm 命令。
CLI 不注入 MCP UI 检查命令;主工程保留原有 client/server 检查,UI 由 Agent 按独立配置执行标准 tsc/eslint。
创建首个 UI 时一并生成以下两个文件,不要为没有 UI 的应用创建配置。
server/mcp/ui/tsconfig.json:
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"types": [],
"strict": true,
"allowJs": true,
"checkJs": true,
"skipLibCheck": true,
"noEmit": true,
"baseUrl": "../../..",
"paths": { "@shared/*": ["shared/*"] }
},
"include": ["./**/*"],
"exclude": ["node_modules", "dist", "eslint.config.cjs"]
}server/mcp/ui/eslint.config.cjs:
module.exports = require('@lark-apaas/fullstack-presets/lib/mcp/eslint-ui');主应用 ESLint 的 UI 排除由平台 ESLint preset 提供,CLI 不改写应用的 ESLint 配置。存量 tsconfig.node.json 已显式声明 exclude 时,sync 只追加 server/mcp/ui;缺少 exclude 时保留原配置并提示人工处理,避免覆盖继承语义。
主工程 lint 成功不代表 UI 已检查;Agent 必须额外执行上述 UI 检查与构建。TS/ESLint 不检查 HTML 内联脚本,交互代码应放独立 TS/TSX 文件。静态检查不能替代真实宿主内的渲染验收。
两套 Vite 预设均校验实际依赖:只允许 UI 目录、shared 和浏览器适用的 npm 依赖;禁止直接或间接引用 client 业务代码、工具、服务端实现或 Node.js 模块。可复用的纯展示组件放 shared;它们也不得依赖应用 Provider、Router 或登录态。构建检查不能证明第三方组件在所有宿主中可用,仍需 iframe 验收。
子构建不加载主应用 Vite 配置、环境文件、public 目录或 PostCSS 配置。UI 数据交互使用 MCP Apps 宿主通信,不默认访问主应用的同源 API。ui://、工具的 _meta.ui.resourceUri、HTTP 端点及 HTML 输出路径不变。
日志
复用框架 Nest Logger:开发态写入本地服务日志,发布态通过既有 Observable 链路上报,模块为 mcp,source_type=platform,不新增指标或事件埋点。全栈应用无需额外配置;单独使用 McpModule 时需自行接入 Nest Logger 的日志后端。
每个到达 MCP 控制器的请求记录一次完成日志,包含 MCP 方法、工具名/资源 URI/Prompt 名、JSON-RPC 请求 ID、HTTP 状态和耗时。框架自动关联 trace、应用与用户上下文。HTTP 错误、JSON-RPC error、工具 isError: true 均记为失败,提前断开记为 aborted;平台目录查询也记录结果。
不记录原始请求参数、请求头、工具结果、UI HTML 或 Skill 正文;资源 URI 去除查询参数、fragment 和 userinfo。未预期异常记录服务端错误信息和堆栈,业务代码应避免在异常消息中包含凭证或敏感数据。日志接入不改变 MCP 响应协议。
